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
// 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 />} />
</>
);
}// 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 aschildrenor props instead.- Never put
'use client'inpage.tsxorlayout.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
// 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 readscookies(),headers(),connection()or uncached fetches makes the route dynamic. - Cached data:
'use cache'at the top of an async function or component (requirescacheComponents: true), withcacheLife/cacheTagfor 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 theseoskill). loading.tsxwraps the whole page in Suspense. Use it for app routes, not for marketing pages whose content is static.- Cache Components plus
'use cache'insidegenerateMetadatakeeps metadata in(see theseoskill).
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.
// 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.
// 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>;
}// components/motion-features.ts
import { domAnimation } from "motion/react"; // domMax (+~10 KB) only if you need drag or layout animations
export default domAnimation;// 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
// 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
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 that imports and boots the widget on click. JSON-LD uses a native , not next/script.
8. Navigation and caching
prefetches in production when links enter the viewport. Don't add Speculation Rulesprerenderto App Router sites.- Next 16 removed automatic
scroll-behavior: smooth. Opt in with, and still disable it under reduced motion. - Headers for non-Next static paths:
// 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": copypublic/and.next/static/into the image yourself.- Set
turbopack.root/outputFileTracingRootwhen a stray lockfile higher up the tree confuses root detection.
9. Package hygiene
- One animation runtime, one icon set, one carousel (
embla-carouselrather thanswiper). - Codemod
framer-motionimports tomotion/react. Delete duplicates likexterm+@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