The build output is the report card
- First Load JS is what a visitor downloads before the page is interactive. Under ~100 kB is healthy; 200 kB is felt on a phone.
- Shared by all is the framework plus anything imported by the root layout. It is the number that punishes a careless import at the top.
- A route you expected to be
○showing asƒmeans something read cookies, headers orsearchParams— or usedno-store.
Route (app) Size First Load JS
┌ ○ / 1.2 kB 92 kB
├ ● /doors 2.4 kB 95 kB
├ ƒ /doors/[id] 1.8 kB 94 kB
└ ƒ /dashboard 48.1 kB 142 kB
+ First Load JS shared by all 88 kB
○ Static prerendered
● SSG prerendered with generateStaticParams
ƒ Dynamic rendered per request
Where the weight comes from
# npm i -D @next/bundle-analyzer, then
ANALYZE=true npm run build
// the usual culprits
import moment from "moment"; // 70 kB → Intl, or date-fns
import _ from "lodash"; // 70 kB → lodash-es, or write the one function
import * as Icons from "react-icons"; // the whole set → import the three you use
import Chart from "chart.js"; // 200 kB → load it when it is shown
A date library imported by a header component is downloaded by someone who only read the home page. Check what the shared chunk actually contains before optimising a route.
Load it when it is needed
Worth splitting: charts, editors, maps, modals, anything below the fold and heavy. Not worth splitting: a component that is 3 kB — you have added a request to save nothing.
import dynamic from "next/dynamic";
const Chart = dynamic(() => import("./Chart"), {
loading: () => <ChartSkeleton />,
ssr: false, // it needs the DOM anyway
});
// and for something behind an interaction, load it on the interaction
const openEditor = async () => {
const { Editor } = await import("./Editor");
setEditor(() => Editor);
};
The cheapest win is not shipping it
- A server component ships no JavaScript. Every component that does not need state or events should be one.
- Move
"use client"down to the leaf that needs it. - Pass data, not functions: a serialisable prop keeps the boundary where it is.
- Ask whether the interactivity is needed at all — a
<details>element is a free accordion.
The caches, in order
"I changed the data and the page is stale" is nearly always one of these — and the browser one surprises people most, because it survives a client-side navigation back to the page.
| Layer | What it holds | Cleared by |
|---|---|---|
| Request memoisation | One fetch per render | The end of the render |
| Data Cache | Fetch results between requests | revalidateTag, revalidatePath, time |
| Full Route Cache | Rendered HTML of static routes | A deploy, or revalidation |
| Router Cache | Visited routes, in the browser | router.refresh(), a reload, 30 s |
What to measure
- LCP — the largest thing on screen. Usually an image: give it
priorityand the rightsizes. - CLS — layout shift. Reserve space for images, embeds and anything that streams in.
- INP — how quickly the page responds to a tap. Usually too much JavaScript on the main thread.
- Measure on a throttled phone profile, not on your laptop.
next buildplus Lighthouse, and real field data if you have it.
Before optimising anything
Build it, read the table, open the analyser, and throttle the network. Three of the four times, the answer is an image that is not using next/image, a library that should have been three lines, or a "use client" at the top of a file that never needed it.