back to blog

Performance Tips for Next.js Applications

A practical guide to making Next.js apps fast: measuring first, shipping less JavaScript, killing waterfalls, streaming, caching, and keeping it from regressing

#nextjs#performance#optimization

Most "performance tips" lists are a pile of APIs with no context. This one is ordered by what actually moves the numbers your users feel: LCP (how fast the main content shows up), INP (how fast the page responds to input), and CLS (how much the layout jumps around).

Everything below targets the App Router on Next.js 16.

0. Measure before you optimize

Optimizing without a baseline is guessing. Three cheap sources of truth:

The build output. next build prints, per route, whether it's static or dynamic and how much JS it costs. The First Load JS number is the one to watch — it's what the browser must download and execute before the page becomes interactive.

Real user metrics. Synthetic scores lie; field data doesn't. Report Web Vitals from a Client Component mounted in the root layout:

"use client";

import { useReportWebVitals } from "next/web-vitals";

export function WebVitals() {
  useReportWebVitals((metric) => {
    navigator.sendBeacon("/api/vitals", JSON.stringify(metric));
  });

  return null;
}

A bundle map, when First Load JS looks wrong:

ANALYZE=true bun run build

Fix what the data points at. Everything else is cosmetics.

1. Ship less JavaScript

The fastest code is the code you never send. In the App Router, components are Server Components by default — they render to HTML on the server, and their dependencies never reach the browser.

The mistake is putting "use client" at the top of a page because one button deep inside needs onClick. That marks the whole subtree as client code. Push the boundary down to the leaf that actually needs it:

// app/products/page.tsx — Server Component, zero JS shipped
import { AddToCartButton } from "./add-to-cart-button";

export default async function Page() {
  const products = await getProducts();

  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>
          <h2>{product.name}</h2>
          <p>{product.description}</p>
          <AddToCartButton id={product.id} />
        </li>
      ))}
    </ul>
  );
}
// app/products/add-to-cart-button.tsx — the only client code on the page
"use client";

export function AddToCartButton({ id }: { id: string }) {
  return <button onClick={() => addToCart(id)}>Add to cart</button>;
}

Same idea for libraries that only transform data into UI — markdown parsers, syntax highlighters, date formatters, chart renderers. If they don't need browser APIs or user interaction, run them in a Server Component and ship the HTML instead of the library.

For genuinely client-side widgets that are heavy and not needed on first paint (a rich text editor, a map, a video player), defer them:

import dynamic from "next/dynamic";

const Map = dynamic(() => import("./map"), {
  loading: () => <div className="h-96 animate-pulse rounded bg-neutral-800" />,
  ssr: false,
});

Two details people skip: give loading a skeleton with the same dimensions as the real component, or you trade bundle size for layout shift; and only use ssr: false when the component genuinely can't render on the server, since it removes the content from the initial HTML.

2. Kill the waterfalls

This is usually the single biggest win on a server-rendered page, and it's invisible in a bundle analyzer.

// Slow: 300ms + 250ms + 180ms = 730ms
const user = await getUser(id);
const posts = await getPosts(id);
const settings = await getSettings(id);

// Fast: max(300ms, 250ms, 180ms) = 300ms
const [user, posts, settings] = await Promise.all([
  getUser(id),
  getPosts(id),
  getSettings(id),
]);

Only sequence what genuinely depends on the previous result. And when a request can be answered without a fetch, don't pay for it up front:

// Slow path taxes the fast path
async function handle(id: string, skip: boolean) {
  const data = await fetchData(id);
  if (skip) return { skipped: true };
  return process(data);
}

// Guard first, await later
async function handle(id: string, skip: boolean) {
  if (skip) return { skipped: true };
  return process(await fetchData(id));
}

Waterfalls also hide across component boundaries: a layout that awaits, wrapping a page that awaits, wrapping a component that awaits, is three round trips stacked in series. Which brings us to the fix.

3. Stream instead of blocking

A page is only as fast as its slowest await. Suspense breaks that coupling: the shell (header, nav, headings, skeletons) ships immediately, and slow parts stream in as they resolve.

import { Suspense } from "react";

export default function Page() {
  return (
    <>
      <Header />
      <Suspense fallback={<FeedSkeleton />}>
        <Feed />
      </Suspense>
      <Suspense fallback={<SidebarSkeleton />}>
        <Recommendations />
      </Suspense>
    </>
  );
}

async function Feed() {
  const posts = await getPosts();
  return <PostList posts={posts} />;
}

Feed and Recommendations now fetch in parallel, and neither blocks the header. A loading.tsx file does the same thing for a whole route segment.

Put boundaries where the content is genuinely slow — one boundary around the actual slow region beats a dozen sprinkled everywhere, which just produces a page that flickers in ten stages.

4. Cache what doesn't change per request

Next.js 16 with Cache Components gives you an explicit directive instead of implicit fetch caching:

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
};

export default nextConfig;

Then cache at the data level, the UI level, or both:

import { cacheLife, cacheTag } from "next/cache";

export async function getProducts(category: string) {
  "use cache";
  cacheLife("hours");
  cacheTag(`products-${category}`);

  return db.query("SELECT * FROM products WHERE category = $1", [category]);
}

Arguments and closed-over values become part of the cache key, so different inputs get different entries. Always pair a cache directive with a cacheLife profile — the implicit default is rarely what you want.

When data changes, invalidate by tag instead of waiting for expiry:

"use server";

import { revalidateTag } from "next/cache";

export async function createProduct(input: ProductInput) {
  await db.insert(input);
  revalidateTag(`products-${input.category}`);
}

The rule of thumb: cache anything that isn't per-user and isn't per-request. Anything that is — a personalized feed, a live price — stays uncached and goes behind a Suspense boundary, so it streams in after the cached shell instead of blocking it.

5. Images: your LCP is probably one of them

On most content pages the LCP element is an image, so this is where the metric is won or lost.

import Image from "next/image";

<Image
  src="/hero.jpg"
  alt="Product overview"
  width={1200}
  height={630}
  priority
  sizes="(max-width: 768px) 100vw, 1200px"
  placeholder="blur"
/>;
  • priority only on the LCP image — usually exactly one per page. Marking everything priority preloads everything, which is the same as prioritizing nothing.
  • sizes whenever the image is responsive. Without it the browser assumes full viewport width and downloads a needlessly large file, even though the layout renders it at 400px.
  • width and height (or fill with a sized parent) reserve the space before the file arrives. This is your CLS fix.
  • Everything below the fold stays lazy — that's the default, so just don't override it.

6. Fonts: self-hosted, no layout shift

next/font downloads font files at build time, self-hosts them from your own origin, and generates a matched fallback so text doesn't jump when the real face swaps in. No render-blocking request to Google, no FOUT.

import { Inter } from "next/font/google";

const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-inter",
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={inter.variable}>
      <body>{children}</body>
    </html>
  );
}

Load only the subsets and weights you use, and prefer one variable font over five static weights.

7. Make navigation feel instant

<Link> prefetches routes in the viewport in production, so a click has nothing left to download. That's free — until you render a list of 500 links and prefetch all of them:

<Link href={`/posts/${post.slug}`} prefetch={false}>
  {post.title}
</Link>

For long lists, turn prefetching off and re-enable it on hover, so you only pay for the routes the user is actually heading toward.

8. Third-party scripts, last

Analytics, chat widgets, and tag managers are frequently the largest thing on the page and never appear in your own bundle report. Load them out of the critical path:

import Script from "next/script";

<Script src="https://example.com/widget.js" strategy="lazyOnload" />;

afterInteractive for scripts that must run early, lazyOnload for everything that can wait until the page is idle. If a vendor tag makes the page measurably slower, that's a product decision worth escalating — not something to optimize around.

9. A note on metadata

Metadata isn't a performance feature, but a slow generateMetadata becomes one. For browsers, Next.js streams metadata alongside the page. For HTML-limited crawlers it blocks the response until it resolves — so a generateMetadata that hits a slow API delays the entire document for those clients.

Keep it cheap: reuse the data you already fetch for the page (request-level deduplication means the second read is free), or cache it.

10. Stop it from regressing

Every optimization here decays the moment someone adds a dependency. Put a budget in CI:

{
  "ci": {
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
      }
    }
  }
}

A build that fails on a regression is worth more than any single optimization in this article.

Conclusion

If you only do three things: eliminate waterfalls with Promise.all, keep "use client" at the leaves, and stream slow content behind Suspense. Those three cover most of the gap between a slow Next.js app and a fast one. The rest is refinement — and a CI budget so it stays that way.