Next.js in Production

Lesson 1 of 5

Bundles & Lazy Loading

"A common cause of large client bundles is doing expensive rendering work in Client Components. This often happens with libraries that exist only to transform data into UI, such as syntax highlighting, chart rendering, or markdown parsing. If that work does not require browser APIs or user interaction, it can be run in a Server Component." — Next.js Documentation, "Package Bundling"

The instinct when a bundle is too large is to split it. Lazy-load the heavy component, defer the chart library, put the editor behind a dynamic import — and all of that works, in the sense that the initial bundle shrinks and the bytes arrive later instead.

The documentation points at something better first, and the example it uses is worth sitting with. A blog post renders highlighted code. The highlighter runs in a client component, so the entire tokenising library — every language grammar, every theme — ships to the browser. And the final output is a <code> block containing spans with class names. The library exists to produce that markup, the markup does not change after it is produced, and nothing about it needs a browser.

Move the same component to the server and the library never enters the bundle at all. Not deferred — absent. That is a categorically different outcome from lazy loading, and it is available for a whole class of dependency: anything whose only job is turning data into markup.

So this chapter goes in that order. First: what should not be in the bundle at all. Then: next/dynamic, for the things that genuinely must be there and can arrive later. Then the two restrictions that catch people, and the configuration options for packages that misbehave.

The big question

Before you defer a dependency, ask whether it needed to reach the browser at all — and what, specifically, it does there that a server could not.

First, do not ship it

Compare the two arrangements of the same feature.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

That last comparison is the framing to keep. Lazy loading rearranges when bytes arrive. Moving work to the server changes whether they exist. The second is strictly better when it is available, and it is available more often than people check.

Everyday analogy

Think about a bakery delivering a cake. Lazy loading is deciding to send the cake after the sandwiches rather than with them — the customer sees lunch sooner, and the same amount of food arrives. Moving work to the server is realising that you have been posting the customer a mixer, a bag of flour and a recipe, when what they wanted was a cake. They cannot use the mixer. They have nowhere to put the flour. Every one of those parcels was necessary at some point in the process, and none of them needed to be at the destination. The question worth asking about any large dependency is which of those two situations you are in, and the tell is simple: does the recipient ever do anything with it, or do they only look at what it made?

next/dynamic, and what it actually is

For the things that genuinely belong in the browser, next/dynamic is the tool — and knowing what it is composed of explains its behaviour.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

The conflation of "lazy" with "client-only" is the misunderstanding worth clearing. A dynamically imported client component still prerenders by default; ssr: false is an additional, separate opt-out with its own consequences.

A little history

Code splitting arrived in Webpack 1 around 2014 and immediately became one of those techniques everybody agreed with and few implemented, because doing it by hand meant reasoning about a dependency graph you could not see. The tooling improved — bundle analysers made the graph visible, React.lazy in 2018 made the component-level split idiomatic — and for most of the last decade "reduce your bundle" meant "split it better". What changed with Server Components is that a third option appeared, and it is worth noticing that it is not an improvement to splitting; it is a different answer to the question. For twenty years the browser was the only place application code could run, so every dependency was a client dependency and the only variable was arrival time. Now a library that turns data into markup has somewhere else to live. I find this the clearest example in the whole volume of a constraint being lifted rather than optimised: the old advice was not wrong, it was the best available answer to a question that has since changed. Which is a good reason to re-examine performance folklore whenever an architecture shifts, because a lot of it encodes assumptions nobody restates.

Two restrictions that catch people

Both concern the boundary, both are documented, and both produce confusing outcomes rather than obvious ones.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

The first rule is the one to internalise because it fails silently. You write the dynamic import in a server component, the code is valid, the page works, and the bundle is unchanged. Nothing tells you the optimisation did not happen.

The engineer's notebook

My first move on any bundle-size complaint is to open the analyser and sort by size, and then — before considering any splitting — to ask of each of the top five entries whether it responds to the user or only produces markup. That single question has moved more bytes for me than any amount of lazy loading, because the answer is "only produces markup" far more often than people expect: highlighters, formatters, chart renderers, markdown parsers, sanitisers. The second habit is a scepticism about dynamic imports written in server components. When I see one I check whether the file has 'use client' at the top, because if it does not, that import is decorative — valid, harmless, and doing nothing for the bundle. It is a particularly frustrating thing to find, because somebody did the work and got no benefit and had no way to tell. The third is about barrel imports: any dependency exporting hundreds of names goes in optimizePackageImports unless it is on the automatic list, and I check the analyser afterwards rather than assuming, because "I configured it" and "it worked" are different claims.

Packages with hundreds of exports

A specific and very common cause, with a specific fix.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

The shape of this problem is worth recognising in general: a package with a single entry point re-exporting everything (a "barrel") makes every import look like an import of the whole library, and a bundler without help cannot always prove otherwise.

In the real world

A documentation site had a 400 KB client bundle on every page and a team convinced they had a splitting problem. They had already lazy-loaded the search dialog and the code playground, and were planning to split the navigation tree next. What the analyser showed, once somebody opened it, was that the largest single entry was a syntax highlighter — pulled in by a code block component that had 'use client' at the top for no reason anybody could remember, probably copied from another component that needed it. Removing that one directive moved the highlighter to the server and took roughly a third off every page, and the output was byte-identical because the component had never done anything interactive. The planned splitting work was cancelled. What I found instructive was that the team's diagnosis was reasonable — the bundle was too big, splitting makes bundles smaller — and it was aimed at the wrong layer entirely. They were optimising the delivery of something that should not have been delivered, and nothing about the symptom pointed at the cause. Opening the analyser first would have saved a fortnight.

Magic comments and server externals

Two escape hatches, both narrow, both worth knowing before you need them.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

Common Mistakes

  1. Splitting a dependency that should not be in the browser at all. Lazy loading changes when bytes arrive; moving the work to a server component removes them.

  2. Marking a component 'use client' without a reason. That is how a whole rendering library ends up in the bundle for markup that never changes.

  3. Calling dynamic() from a server component to split a client component. Automatic code splitting is not supported for that case, so it compiles and achieves nothing.

  4. Using ssr: false in a server component. That is an error; move it into a client component.

  5. Assuming a dynamic import means client-only. Client components are still prerendered by default; ssr: false is a separate decision.

  6. Dynamically importing a server component expecting the server component to be deferred. Only its client children are lazy-loaded.

  7. Splitting before opening the analyser. The largest entry is frequently not the one you were planning to split.

  8. Ignoring barrel-file packages. A library exporting hundreds of names may bundle in full without optimizePackageImports.

  9. Adding a package to optimizePackageImports that is already optimised automatically, and not verifying the result either way.

  10. Putting a magic comment on a static import. They only apply to dynamic expressions.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Junior
How do you lazy-load a component in Next.js?
Model answer — With next/dynamic: const Chart = dynamic(() => import('./Chart')). It is a composite of React's lazy and Suspense. If you render it behind a condition — inside a modal that is closed, say — the code is not fetched until the condition becomes true. There is also a { ssr: false } option to make it client-only, which is a separate thing from splitting: a dynamically imported client component is still prerendered by default.
Mid
Your client bundle is too large. Where do you start?
Model answer — The analyser, before deciding anything — and then for each of the biggest entries I would ask whether it responds to the user or only turns data into markup. Highlighters, markdown parsers, chart renderers and formatters fall in the second group, and those can run in a server component and never enter the bundle at all, which is better than deferring them. Very often the cause is a 'use client' directive somebody added without needing it. After that I would look for barrel-file packages that bundle in full and add them to optimizePackageImports, and only then reach for splitting, for the things that genuinely need a browser and are not needed immediately.
Senior
A colleague adds dynamic() imports in a server component and the bundle does not change. Why?
Model answer — Because automatic code splitting is not supported when a Server Component dynamically imports a Client Component. The code is valid, the page renders, and the optimisation simply does not happen — which makes it one of the more frustrating things to find, since the work was done and nothing reports that it had no effect. Splitting a client component has to be initiated from a client component. Worth knowing alongside that: ssr: false in a server component is an outright error rather than a silent no-op, and dynamically importing a server component does something different again — it lazy-loads that component's client children and preloads its CSS, rather than deferring the server component itself. The broader point I would make in review is that this whole class of change should be verified in the analyser, because several of the operations here look identical in source and differ completely in effect.
Company lens — how the same topic gets asked
micro1Practical: defer a heavy modal so its code is not fetched until it opens.
GoogleReasoning: when is moving work to the server strictly better than splitting, and why?
MetaAudit: given an analyser report, decide what to do with each of the top entries.
AmazonDiagnosis: a splitting change had no measurable effect. Explain how that happens.
Hands-on

Two functions. The first decides what to do with a dependency — leave it on the server, split it, or ship it eagerly — where the rule that matters is that anything not needing a browser should not be in the bundle at all, whatever its size. The second validates a next/dynamic usage and reports whether it actually splits, which is where the two documented restrictions live: the silent no-op and the outright error.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

Summary

  • Ask whether a dependency belongs in the browser before deciding when to send it. Lazy loading changes arrival time; moving work to a server component removes the bytes.

  • Libraries that only turn data into markup can run on the server — highlighters, markdown parsers, chart renderers, formatters.

  • An unnecessary 'use client' is how those libraries end up in the bundle for output that never changes.

  • next/dynamic is React.lazy plus Suspense, with the import boilerplate handled.

  • A dynamically imported client component is still prerendered. ssr: false is a separate decision about server HTML, not about splitting.

  • A component rendered behind a condition is not fetched until the condition is true, which avoids its cost rather than deferring it.

  • A server component dynamically importing a client component does not get automatic code splitting. It compiles, works, and changes nothing.

  • ssr: false in a server component is an error; move it into a client component.

  • Dynamically importing a server component lazy-loads its client children and preloads its CSS, not the server component itself.

  • Barrel packages may bundle in full; optimizePackageImports fixes that, some libraries are handled automatically, and server-side packages are bundled by default with serverExternalPackages to opt out.

Bundles are what you send. The next chapter is about what happens to a response after it leaves your server — the caching layer you do not control, the headers that instruct it, and why a CDN can undo careful work at either end. It is CDN & Edge Caching.

Editorial reflection

What I find genuinely interesting here is how durable a piece of performance folklore can be after its premise changes. "Split your bundles" was correct advice for a decade, and it was correct because there was exactly one place code could run. Under that constraint, arrival time was the only variable, and every technique we developed — splitting, prefetching, tree shaking, preloading — was a way of arranging a fixed quantity of code more cleverly. Server Components did not improve any of those techniques. They added a second place, which turns a scheduling problem into a placement problem, and placement questions have better answers available. The reason the old advice persists is not stupidity; it is that it still works, and something that still works is very hard to notice as second-best. That is the pattern I would watch for generally. When an architecture changes, the wrong advice announces itself quickly because it breaks. The merely superseded advice keeps producing improvements, keeps getting reinforced, and quietly caps how good your results can be.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.