Back to blog

Speeding Up a Next.js App: The Techniques That Actually Matter

July 24, 20262 min read
Speeding Up a Next.js App: The Techniques That Actually Matter

Most Next.js performance work isn't clever — it's a checklist. Here's the one that actually moves the needle, roughly in order of effort-to-payoff.

1. Images and fonts — the guaranteed wins

  • next/image serves AVIF/WebP automatically and won't cause layout shift as long as width/height (or fill + a sized parent) are set — this alone usually fixes CLS.

  • next/font self-hosts the font file and injects it at build time, so there's no render-blocking request to a font CDN and no swap-triggered layout shift.

2. Default to static, opt into dynamic

Render statically with ISR wherever the data isn't request-specific, and only reach for fully dynamic rendering when a page genuinely needs per-request data (auth state, personalization). A blog post or project page almost never needs to be dynamic.

export const revalidate = 3600; // regenerate at most once an hour

export async function generateStaticParams() {
  const posts = await postRepository.findAllPublished();
  return posts.map((post) => ({ slug: post.slug }));
}

3. Stream slow, non-critical data with Suspense

Wrap anything that's slow and not needed for the initial paint in its own Suspense boundary with a lightweight fallback, so the rest of the page ships immediately instead of waiting on the slowest query.

<Suspense fallback={<CommentsSkeleton />}>
  <Comments postId={post.id} />
</Suspense>

4. Fetch in parallel, not in sequence

// Slow — the second fetch waits for the first to finish for no reason.
const project = await getProject(slug);
const related = await getRelatedProjects(project.tags);

// Fast — both requests fire at the same time.
const [project, allProjects] = await Promise.all([getProject(slug), getAllProjects()]);
const related = allProjects.filter((p) => p.tags.some((t) => project.tags.includes(t)));

5. Keep the client bundle small

  • Push "use client" as far down the tree as possible — a page can be 95% Server Components with one small interactive leaf; don't mark a whole page or layout client just because one button needs an onClick.

  • Lazy-load heavy, below-the-fold client components (charts, rich text editors, modals) with next/dynamic so their JS isn't in the initial bundle.

  • Import icons and utilities by name from libraries that support it, and set experimental.optimizePackageImports in next.config for the ones that don't tree-shake cleanly on their own.

6. Cache and dedupe reads

fetch() is automatically memoized per request in the App Router. For anything that doesn't go through fetch — an ORM call, a direct DB query — wrap it in React's cache() to get the same per-request deduplication, so five Server Components asking for the same project only hit the database once.

import { cache } from "react";

export const getProject = cache(async (slug: string) => {
  return prisma.project.findUnique({ where: { slug } });
});

7. Measure before optimizing

Read the route sizes in the next build output, run @next/bundle-analyzer when something feels heavy, and check real Core Web Vitals (LCP, INP, CLS) rather than guessing. Optimizing a route that's already fast wastes time that a genuinely slow one needed.