---
title: "Next.js 16 App Router performance: specifics (speed)"
description: "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."
canonical: https://void-design.vercel.app/docs/speed/next-performance
lastModified: 2026-09-16
---

# Next.js 16 App Router performance: specifics

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.
- `<Suspense fallback={<Skeleton className="h-[420px]" />}>` 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 `<head>` (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 `<MotionConfig reducedMotion="user">` 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 `<button>` that imports and boots the widget on click. JSON-LD uses a native `<script type="application/ld+json">`, not `next/script`.

## 8. Navigation and caching

- `<Link>` prefetches in production when links enter the viewport. Don't add Speculation Rules `prerender` to App Router sites.
- Next 16 removed automatic `scroll-behavior: smooth`. Opt in with `<html data-scroll-behavior="smooth">`, and still disable it under reduced motion.
- Headers for non-Next static paths:

```ts
// next.config.ts
async headers() {
  return [
    { source: "/fonts/:path*", headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }] }, // only if file names are hashed
    { source: "/docs/:path*", headers: [{ key: "Cache-Control", value: "public, s-maxage=3600, stale-while-revalidate=86400" }] },
  ];
},
```

- `output: "standalone"`: copy `public/` and `.next/static/` into the image yourself.
- Set `turbopack.root` / `outputFileTracingRoot` when a stray lockfile higher up the tree confuses root detection.

## 9. Package hygiene

- One animation runtime, one icon set, one carousel (`embla-carousel` rather than `swiper`).
- Codemod `framer-motion` imports to `motion/react`. Delete duplicates like `xterm` + `@xterm/xterm`.
- Your own packages: per-component `exports`, `"sideEffects": ["*.css"]`, no internal barrel files in client code.
- A dependency diet measured on a real portfolio: 21 → 9 deps, first-load JS 225 → 115 KB, build 47 s → 4.5 s.

## Sources

- https://nextjs.org/blog/next-16 · https://nextjs.org/blog/next-16-3
- https://nextjs.org/docs/app/guides/package-bundling
- https://nextjs.org/docs/app/api-reference/config/next-config-js/optimizePackageImports
- https://nextjs.org/docs/app/api-reference/components/script
- https://nextjs.org/docs/app/api-reference/components/link
- https://motion.dev/docs/react-reduce-bundle-size
- https://react.dev/reference/react/useTransition
