Heavy components such as charts, editors, and interactive maps can add JavaScript to a page before a visitor needs them. In Next.js, dynamic imports let you split a component into a separate chunk; rendering it only after an interaction can also delay loading until it is useful. This tutorial shows a small App Router example and explains the tradeoffs.
Key Takeaways
- Use
next/dynamicto dynamically import a component and provide a loading fallback. - Define the dynamic import at module scope with a literal path so Next.js can analyze it.
- To wait for user intent, conditionally render the dynamic component after an interaction.
ssr: falseis for components that cannot render on the server, and must be used from a Client Component.- Lazy loading can reduce the JavaScript needed up front, but adds a later download and loading delay.
What lazy loading changes
A dynamic import lets the bundler put a component in a separately loaded chunk rather than including it in the initial client-side JavaScript for the importing component. The browser requests that chunk when the component is needed. With next/dynamic, you can show a fallback while it loads.
There are two related choices:
- Split the code, render right away: the dynamic component is requested as the component renders. This can help divide code, but does not necessarily postpone the request until a user action.
- Split the code and render on demand: do not mount the component until the user asks for it. This can postpone its request, but adds a wait after that action.
Lazy loading is most useful when a component is relatively costly and is not needed for the initial view. It may not help much for a small component, one needed immediately, or one most visitors open right away.
Build a click-to-load example
The example uses the Next.js App Router. It has a Server Component page and a small Client Component that loads a chart only after a button click. The chart uses plain React and SVG so the example does not depend on a particular chart library; replace it with your own heavier component when applying the pattern.
1. Add the client-side loader
Create app/components/ChartSlot.jsx:
"use client";
import { useState } from "react";
import dynamic from "next/dynamic";
const HeavyChart = dynamic(() => import("./HeavyChart"), {
loading: () => <p role="status">Loading chart…</p>,
});
export default function ChartSlot() {
const [showChart, setShowChart] = useState(false);
return (
<section aria-labelledby="chart-heading">
<h2 id="chart-heading">Monthly signups</h2>
<p>The chart is loaded only when you request it.</p>
{!showChart ? (
<button type="button" onClick={() => setShowChart(true)}>
Show chart
</button>
) : (
<HeavyChart />
)}
</section>
);
}
dynamic() is declared at the top level, and its import path is a literal. The component is not mounted until showChart becomes true, so its dynamic chunk is needed only after the click. The loading option supplies a fallback while that chunk loads. A very fast local load may make the fallback hard to notice.
2. Add the dynamically imported component
Create app/components/HeavyChart.jsx:
"use client";
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"];
const values = [18, 32, 25, 46, 38, 61];
export default function HeavyChart() {
return (
<div>
<svg
viewBox="0 0 360 150"
role="img"
aria-label="Monthly signups trend from January to June"
>
<polyline
points={values
.map((value, index) => `${index * 60 + 20},${130 - value * 1.7}`)
.join(" ")}
fill="none"
stroke="currentColor"
strokeWidth="3"
/>
</svg>
<p>{months[0]}–{months[months.length - 1]}: signups increased overall.</p>
</div>
);
}
This deliberately small component demonstrates the import boundary, not a performance gain: it is not a heavy chart. In a real application, the dynamically imported module might use a charting library, a rich-text editor, or another substantial dependency.
3. Render the loader from the page
Create or update app/page.jsx:
import ChartSlot from "./components/ChartSlot";
export default function Page() {
return (
<main>
<h1>Signup report</h1>
<p>View the chart when you need the detailed trend.</p>
<ChartSlot />
</main>
);
}
The page remains a Server Component by default. ChartSlot is the client-side boundary because it uses state and handles a click. Its dynamic import is declared inside that Client Component, where it can be loaded on demand.
Loading fallbacks and server rendering
A fallback is useful when the component takes long enough to load that the interface would otherwise feel unresponsive. Keep it meaningful: a short status message or a layout-matched placeholder is usually better than a blank area. For a more substantial interface, set an appropriate minimum height so content below it does not jump when the component appears.
By default, next/dynamic supports server rendering for dynamically imported components. Use that default when the component can render on the server and server-rendered output is useful. A dynamic import alone does not mean the component is deferred until a click; that requires conditional rendering or another deliberate loading trigger, as in the example.
Some libraries access browser-only APIs such as window during module initialization and cannot be rendered on the server. For those cases, ssr: false disables server rendering for that dynamic component. It must be set in a Client Component, not a Server Component. For example, in ChartSlot.jsx, the declaration could be changed to:
const HeavyChart = dynamic(() => import("./HeavyChart"), {
ssr: false,
loading: () => <p role="status">Loading chart…</p>,
});
Use this option only when server rendering is incompatible or unwanted. It can delay visible content until the browser loads the component. If a dependency can be made server-compatible, keeping server rendering may provide a better initial experience.
Bundle size versus later loading
Lazy loading changes when code is fetched, not whether the feature's code is needed at all. The initial client-side JavaScript can be smaller when a deferred component is excluded from the initial path, but the visitor may wait for an additional request and download when opening it. Network conditions, caching, route prefetching, and the component's size all affect the result.
Use the pattern for optional, substantial interface features rather than splitting every component. After making a change, inspect a production build and test the interaction on a throttled connection. Compare the initial experience with the time from the user's action to the component becoming usable. Do not assume a smaller initial bundle automatically means a faster overall experience for every visitor.
Common Mistakes
- Expecting
dynamic()to wait for a click: it creates a dynamic import, but the component is requested when it is rendered. Delay rendering as well if it should load only on demand. - Calling
dynamic()inside a component function: define it at module scope with a literal import path so Next.js can analyze the import. - Using
ssr: falsein a Server Component: put that dynamic declaration in a Client Component. - Lazy loading something needed immediately: the extra request and fallback can make important content appear later without a useful initial-load benefit.
- Treating the fallback as the finished interface: provide appropriate accessibility and layout behavior, and make sure users can tell that loading is in progress.
Best Practices
- Defer components that are expensive and genuinely optional, such as a detailed chart below the fold or an editor opened on demand.
- Keep the server-rendered page useful before the client component loads.
- Choose a fallback that communicates progress and avoids unnecessary layout shifts.
- Test both the first load and the on-demand interaction in a production build; development behavior is not a substitute for production measurement.
- Check whether the real dependency is compatible with server rendering before disabling SSR.
Conclusion
In Next.js, next/dynamic provides a practical way to split component code, while conditional rendering lets you defer a component until a user requests it. Choose lazy loading when the later download is a worthwhile tradeoff for keeping optional code out of the initial experience.
