Back to blog

Common Next.js Mistakes That Quietly Kill Performance

July 24, 20263 min read
Common Next.js Mistakes That Quietly Kill Performance

Most slow Next.js apps aren't slow because of one big problem — they're slow because of a handful of small, recurring habits that quietly cancel out everything the framework does for free. Here are the ones that show up over and over.

1. "use client" too high in the tree

Marking a whole page or layout as a Client Component because one button needs an onClick drags everything under it into the client bundle and turns off server rendering for the whole subtree. Keep the boundary as small as possible — a page can stay a Server Component with one small interactive island inside it.

// Before — the whole page opts out of Server Components.
"use client";
export default function ProjectPage({ project }) {
  return (
    <article>
      <h1>{project.title}</h1>
      <LikeButton projectId={project.id} />
    </article>
  );
}

// After — only the interactive piece is a Client Component.
export default function ProjectPage({ project }) {
  return (
    <article>
      <h1>{project.title}</h1>
      <LikeButton projectId={project.id} />
    </article>
  );
}
// LikeButton.tsx
("use client");
export function LikeButton({ projectId }) {
  /* ... */
}

2. Sequential awaits that don't need to be sequential

Two independent fetches, awaited one after another, add their latencies together for no reason. If the second doesn't depend on the first's result, fire them together.

// Both requests are independent — but this takes latencyA + latencyB.
const author = await getAuthor(post.authorId);
const comments = await getComments(post.id);

// This takes max(latencyA, latencyB).
const [author, comments] = await Promise.all([
  getAuthor(post.authorId),
  getComments(post.id),
]);

3. One slow query blocking the entire page

Without a Suspense boundary around it, a single slow data source (an external API, a heavy aggregation query) delays the first byte of the whole page, even though the rest of the content was ready instantly.

4. Images without dimensions or priority

An <img> — or even next/image without width/height/fill — reserves no space until it loads, which is a direct CLS hit. And the hero image above the fold should get priority so it isn't lazy-loaded behind everything else.

5. Barrel-file imports that defeat tree-shaking

Importing a single icon from a library's giant barrel index can pull in far more than intended, depending on how the bundler resolves it. Prefer direct/named imports where the library supports them, and set optimizePackageImports for the ones that don't tree-shake well on their own.

// next.config.ts
const nextConfig = {
  experimental: {
    optimizePackageImports: ["lucide-react", "date-fns"],
  },
};

6. Forgetting cache/revalidate config

Using any dynamic API (cookies(), headers(), an uncached fetch) anywhere in a route opts the whole route out of static rendering — often by accident, turning a page that should be instant into one rebuilt from scratch on every single request.

7. Re-fetching the same data across sibling components

Several Server Components in the same request tree independently calling the same data-access function, without memoization, each hit the database separately. Wrapping the function in React's cache() collapses them into one call per request.

None of these are exotic — they're all things that look completely reasonable in a code review, one component at a time, and only add up to a real problem once you look at the whole page.