Skip to content

Reference · speed

Next.js 16 App Router performance: specifics

speed/references/next-performance.md232 linesupdated 16 Sept 2026

Verified against Next 16.3.5, React 19.2, Tailwind 4.3.3 (production builds measured 2026-09-16). The canonical config is templates/next/next.config.ts.

1. Baseline facts

Fact Value
Hello-world App Router first-load JS 133 KB gzip / 114 KB brotli (6 chunks; plus a 39 KB noModule polyfill that modern browsers skip)
+ 3 lucide icons in a Server Component +1 KB
+ LazyMotion + m + domAnimation +32 KB
+ full motion.div +46 KB
Default Tailwind v4 CSS for the starter page 4.1 KB gzip / 3.6 KB brotli
next build output no "First Load JS" column any more. Measure it yourself (§4)
next start compression gzip only, even when br/zstd are accepted
Browser targets Chrome/Edge/Firefox 111+, Safari 16.4+

2. Component boundaries

tsx
// app/page.tsx: Server Component (no directive)
import { Hero } from "@/components/hero";          // server
import { Pricing } from "@/components/pricing";    // server
import { BillingToggle } from "@/components/billing-toggle"; // 'use client' leaf

export default function Page() {
  return (
    <>
      <Hero />
      <Pricing toggle={<BillingToggle />} />
    </>
  );
}
tsx
// components/disclosure-shell.tsx: client wrapper that receives server children
"use client";
import { useState, type ReactNode } from "react";

export function DisclosureShell({ label, children }: { label: string; children: ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button type="button" aria-expanded={open} onClick={() => setOpen((o) => !o)}>{label}</button>
      <div hidden={!open}>{children /* rendered on the server, zero client JS for this subtree */}</div>
    </div>
  );
}

Rules:

  • 'use client' marks a module boundary. Everything it imports becomes client code, including Server Components. Pass those as children or props instead.
  • Never put 'use client' in page.tsx or layout.tsx. Extract the interactive part.
  • Props that cross the boundary are serialized into the RSC payload (self.__next_f.push) in addition to the HTML. Pass { title, href }, not the whole post.
  • Heavy data→UI work (Markdown, Shiki highlighting with one theme, static SVG charts) runs in Server Components. Measured: docs route 448 KB → 115 KB first-load JS after moving Shiki to build time.

3. Streaming, caching, static

ts
// next.config.ts (excerpt)
import type { NextConfig } from "next";
const config: NextConfig = {
  cacheComponents: true,        // 'use cache' + static shell with dynamic holes (PPR)
  reactCompiler: true,          // stable in 16, not default; auto-memoizes (needs babel-plugin-react-compiler)
  images: { formats: ["image/avif", "image/webp"] },
  experimental: {
    optimizePackageImports: ["@radix-ui/react-icons"], // lucide-react, date-fns, lodash-es, @headlessui/react, recharts, react-icons/* are already optimized by default
  },
  // compress: false,           // only when a CDN/proxy compresses with brotli/zstd
};
export default config;
  • Keep marketing routes static (○ (Static) in the build output). Anything that reads cookies(), headers(), connection() or uncached fetches makes the route dynamic.
  • Cached data: 'use cache' at the top of an async function or component (requires cacheComponents: true), with cacheLife/cacheTag for revalidation.
  • }> around secondary slow content only. Never around the hero or h1. Skeleton height = final height (±8 px).
  • Resolve existence (notFound(), redirect()) before any Suspense boundary. Inside a streamed boundary the status is already 200 (see the seo skill).
  • loading.tsx wraps the whole page in Suspense. Use it for app routes, not for marketing pages whose content is static.
  • Cache Components plus 'use cache' inside generateMetadata keeps metadata in (see the seo skill).

4. Measure first-load JS (Next 16 removed it from build output)

Run after next build. It sums the non-noModule scripts referenced by each prerendered HTML file.

ts
// scripts/firstload.ts: bun scripts/firstload.ts (or node --experimental-strip-types)
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { gzipSync, brotliCompressSync } from "node:zlib";

const root = ".next/server/app";
const walk = (d: string): string[] =>
  readdirSync(d).flatMap((f) => (statSync(join(d, f)).isDirectory() ? walk(join(d, f)) : [join(d, f)]));

for (const file of walk(root).filter((f) => f.endsWith(".html"))) {
  const html = readFileSync(file, "utf8");
  const js = [...html.matchAll(/<script([^>]*)src="\/_next\/(static\/[^"]+\.js)"([^>]*)>/g)]
    .filter((m) => !/nomodule/i.test(m[1] + m[3]))
    .map((m) => m[2]);
  const css = [...html.matchAll(/<link[^>]+rel="stylesheet"[^>]+href="\/_next\/(static\/[^"]+\.css)"/g)].map((m) => m[1]);
  const size = (files: string[], f: (b: Buffer) => Buffer) =>
    Math.round(files.reduce((s, p) => s + f(readFileSync(join(".next", p))).length, 0) / 1024);
  const rsc = [...html.matchAll(/self\.__next_f\.push\((.*?)\)<\/script>/gs)].reduce((s, m) => s + m[1].length, 0);
  console.log(
    file.replace(root, "") || "/",
    `js ${size([...new Set(js)], (b) => gzipSync(b, { level: 9 }))} KB gz`,
    `css ${size([...new Set(css)], (b) => brotliCompressSync(b))} KB br`,
    `html ${Math.round(brotliCompressSync(Buffer.from(html)).length / 1024)} KB br`,
    `rsc ${Math.round((rsc / html.length) * 100)}% of html`,
  );
}

Targets: js ≤ 170 KB (marketing), css ≤ 25 KB, html ≤ 30 KB, rsc ≤ 50%. For dynamic routes, fetch the HTML from next start and apply the same extraction. void perf reports the measured transfer as perf/js-budget.

Explore what's in a chunk: next experimental-analyze (Turbopack, Next ≥ 16.1; --output writes to .next/diagnostics/analyze). @next/bundle-analyzer works only with next build --webpack.

5. Motion without the weight

Prefer CSS (transitions, @starting-style, keyframes, View Transitions). Use Motion only for gestures, layout animations or exit orchestration.

tsx
// components/motion-provider.tsx
"use client";
import { LazyMotion } from "motion/react";
import type { ReactNode } from "react";

const loadFeatures = () => import("./motion-features").then((m) => m.default);

export function MotionProvider({ children }: { children: ReactNode }) {
  return <LazyMotion features={loadFeatures} strict>{children}</LazyMotion>;
}
ts
// components/motion-features.ts
import { domAnimation } from "motion/react"; // domMax (+~10 KB) only if you need drag or layout animations
export default domAnimation;
tsx
// any client leaf
"use client";
import * as m from "motion/react-m";
export const FadeIn = (p: { children: React.ReactNode }) => (
  <m.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }}>{p.children}</m.div>
);

strict throws if someone uses motion.div inside the provider. Never use this for the LCP element. Add at the same level.

6. Deferring heavy widgets

tsx
// components/map-slot.tsx
"use client";                                   // ssr:false is only allowed in client components
import dynamic from "next/dynamic";
import { useEffect, useRef, useState } from "react";

const Map = dynamic(() => import("./map"), { ssr: false, loading: () => null });

export function MapSlot() {
  const ref = useRef<HTMLDivElement>(null);
  const [show, setShow] = useState(false);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver(([e]) => e.isIntersecting && (setShow(true), io.disconnect()), { rootMargin: "200px" });
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return <div ref={ref} className="aspect-[16/9] w-full bg-surface">{show && <Map />}</div>;
}

Candidates: maps, code editors (Monaco ~2 MB), terminals, Sandpack, Mermaid, 3D, carousels below the fold.

7. Third-party scripts

tsx
import Script from "next/script";
// app/layout.tsx, inside <body>
<Script src="https://analytics.example.com/script.js" strategy="lazyOnload" data-site="…" />
Strategy Use for
beforeInteractive consent manager or bot detection only (root layout)
afterInteractive (default) analytics that must measure the first view
lazyOnload everything else: chat, social, heatmaps, A/B tags
worker does not work with App Router. Don't use

Facade for chat: render a styled