skip to content

Dynamic Imports and Bundle Analysis

Next splits by route for free, but heavy client-only widgets still need next/dynamic and someone willing to read the analyzer output. Interviewers ask how you'd find out why the first-load JS number went up.

part ofNext.jsoverview, primer and where to startread it →
on this pageshow

explore

questions

5

In a Next.js App Router app, do you have to configure anything for one route's JavaScript to stay out of another route's initial download, and what is next/dynamic for then?

level: juniorimportance: must knowfreq 60%

answer

  1. routes are already separate downloads
  2. the extra split lives inside one route
  3. deferral, not deletion
  4. interaction-gated, and big relative to the route
  5. loading fallback reserves the space

basics

~10 s

Nothing to configure: Next.js code-splits per route automatically, so visiting /dashboard does not download /settings' client code. next/dynamic splits inside a route, deferring a heavy component until it is actually rendered or needed.

solid answer

~40 s

Route-level splitting is free in the App Router. Each route segment's client components end up in their own chunks, plus a shared chunk for what every route needs (the framework runtime, the root layout's client code, widely imported modules). You never touch bundler config for that. `next/dynamic` is the *within-route* tool: `const Chart = dynamic(() => import('./Chart'))` keeps that component out of the chunk the route loads up front and fetches it when it first renders. You reach for it when a single route carries something heavy that most visitors never see — a chart library, a rich text editor, a map, a modal's contents — or something that only works in the browser. It is a deferral, not a deletion: the code is still built and shipped, just requested later.

go deeper

for a junior

Know that Next splits JavaScript per route with no setup, and that next/dynamic is how you defer one heavy component inside a route. Be able to write the dynamic() call with a loading fallback.

for a middle

Explain that the import graph, not the render branch, decides what ships, so a conditionally rendered component still lands in the eager chunk. Say what the loading option is for and why deferral is not deletion.

for a senior

Show you pick split points from measurements: which routes are heavy, what fraction of users hit the deferred path, and what the added request latency costs at the moment of interaction. Mention prefetching on intent and verifying the drop in the build output.

for a principal

Own the policy question — where splitting is worth the extra chunk and where the team should instead cut the dependency or move work to the server. Set a budget the build output can be checked against so splitting decisions stop being folklore.

## The default: one split per route In the App Router you do not configure code splitting. When Next builds, every route segment gets its own client JavaScript chunks, and modules needed by many routes are hoisted into shared chunks. A visitor landing on `/dashboard` downloads the framework runtime, the shared chunks, the client code of the layouts wrapping that route, and that route's own client code — and nothing that belongs only to `/settings`. This is why the `next build` route table prints a per-route number alongside a "shared by all" number: the per-route figure is what that page adds on top of the common baseline. ## What that default does not do Route splitting is *coarse*. It says nothing about the size of a single route. If one page statically imports a 300 kB charting library at the top of a client component, that library lands in the chunk the route needs before it can render — even if the chart lives behind a tab nobody clicks. ```tsx 'use client' import { Chart } from 'heavy-chart-lib' // downloaded on first paint of this route export function Panel({ open }: { open: boolean }) { return open ? <Chart /> : <p>Open the panel to see the chart.</p> } ``` The conditional render does not help. Whether a module ships is decided by the import graph at build time, not by which branch runs. ## What next/dynamic adds `next/dynamic` turns a static import into a lazily fetched chunk that is requested when the component first renders: ```tsx 'use client' import dynamic from 'next/dynamic' const Chart = dynamic(() => import('./Chart'), { loading: () => <p>Loading chart…</p>, }) ``` The factory must be a literal `import()` the bundler can see statically — a computed specifier gives it nothing to split on. If the module exports the component by name rather than as a default, resolve it in the factory: ```tsx const Chart = dynamic(() => import('./Chart').then((mod) => mod.Chart)) ``` Two options matter. `loading` supplies the placeholder shown while the chunk is in flight — give it roughly the height of the real thing so the page does not jump when it arrives. `ssr: false` additionally tells Next not to render the component on the server at all, which is how you host browser-only libraries. ## It defers; it does not delete A dynamically imported module is still compiled, still deployed, and still downloaded by anyone who reaches the code path. The win is *when*, not *whether*. That has two consequences worth saying out loud in an interview: - **Total bytes can go up.** More chunks means more requests and a little duplicated boilerplate. Splitting a 4 kB component is pure overhead. - **The deferred fetch has to happen sometime**, and it happens at the moment the user asked for the thing. If that moment is a click, the spinner is visible latency you have moved rather than removed. Prefetching on hover or intent is the usual mitigation. ## When it is actually worth it Good candidates: a component that is large relative to the route (tens of kilobytes and up), that a minority of visitors on that route ever sees, and whose appearance is already gated behind an interaction — modals, drawers, editors, maps, charts, video players, admin-only panels. Bad candidates: anything above the fold, anything on the critical render path, and anything small. Also note the pull in the other direction: if two routes each dynamically import the same heavy module, the bundler will usually put it in a chunk they share, so the first route to load it pays and the second gets it from cache. ## Checking your work The honest check is the build output before and after: did this route's First Load JS actually drop? If it did not, the module is still reachable from something that loads eagerly — a static import elsewhere in the same route's graph will keep it in the eager chunk no matter how many `dynamic()` wrappers point at it. `@next/bundle-analyzer` shows which chunk it landed in and what dragged it there.

  • If splitting is automatic per route, why does the build output still report a chunk shared by every route?
    Because some code genuinely is common: the framework runtime, the root layout's client components, and modules imported from enough places that the bundler hoists them instead of duplicating them per route. That baseline is downloaded once and cached, so it counts toward every route's first load but only costs the user on the first page they visit.
  • Does wrapping a component in next/dynamic remove it from the deployed build?
    No. It is still compiled and shipped as its own chunk; you have only changed when the browser requests it. Anyone who triggers the code path downloads it then. If your goal is to stop shipping the code at all, the answer is deleting the dependency or moving the work to the server — not a lazy import.
  • What happens if you pass a computed module path to next/dynamic, such as dynamic(() => import(`./widgets/${name}`))?
    The bundler can no longer identify a single target, so it either fails or bundles every module matching the pattern into the split — usually the opposite of what you wanted. Keep the specifier a literal string, and use an explicit map from key to a distinct `dynamic()` call when you need to choose at runtime.

saying these in an interview costs you the question

  • Thinks you must hand-configure webpack splitChunks in Next
  • Says next/dynamic reduces the total JavaScript shipped
  • Believes a conditional render keeps an imported module out of the bundle
  • Wraps small components in dynamic() and calls it an optimization
  • Confuses lazy-loading code with lazy-loading data

context

open as a page

In a Next.js App Router app, what does the `ssr: false` option on `next/dynamic` change about where a component renders, and why does the build reject that option inside a Server Component?

level: middleimportance: must knowfreq 64%

basics

~20 s

With ssr: false, Next.js skips the component during server rendering and mounts it only in the browser after hydration, rendering the loading fallback in its place meanwhile. The App Router rejects the option in a Server Component because opting out of server rendering is a client-side decision.

open as a page

A Next.js app's First Load JS has grown by roughly 150 kB over a sprint and nobody knows which change did it. How would you use @next/bundle-analyzer to find the cause, and what do you typically find?

level: seniorimportance: must knowfreq 58%

basics

~20 s

Wire @next/bundle-analyzer into next.config, build with ANALYZE=true, and read the client treemap: find the largest unexpected block, then trace which module pulled it in. The usual culprits are a barrel import, a heavy dependency added to a shared client component, and a library dragged across the client boundary.

open as a page

After running `next build` on a Next.js app, the route table prints a "First Load JS" column per route plus a "First Load JS shared by all" line. What does each number actually count?

level: middleimportance: should knowfreq 50%

basics

~20 s

First Load JS is all the JavaScript a browser downloads to render that route on a cold visit: the route's own chunks plus the shared chunks every route needs. The "shared by all" line breaks out that common baseline, which is included in every route's figure.

open as a page

A team wraps a heavy charting component in `next/dynamic`, but the Next.js build output shows the route's First Load JS unchanged and the analyzer still puts the charting library in a chunk fetched on first paint. What explains that, and how would you confirm it?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Something else still imports that module eagerly. A lazy import only defers a module nothing else reaches statically, so one ordinary import elsewhere in the route's graph — a barrel, a type-and-value import, a shared component — keeps the library in the eager chunk regardless of the dynamic wrapper.

open as a page