
Lazy Loading Components in Next.js with next/dynamic
Not every component on a page is needed the moment the page loads. The rich text editor only matters once someone clicks "Edit". The chart sits three screens down. The settings modal may never be opened at all. If all of that code is in the page's main JavaScript bundle, every visitor pays to download, parse, and execute it, whether they use it or not.
Lazy loading splits those components into separate chunks that load only when they're actually rendered. In Next.js the main tool for this is next/dynamic. This post covers how it works in the App Router, the options it takes (loading, ssr), how to lazy load named exports and plain libraries, how to load components on interaction, on scroll, or ahead of time on hover, and the cases where lazy loading makes things worse.
What Lazy Loading Applies To
In the App Router, Server Components are already split per route, and their code never reaches the browser at all. So lazy loading is about Client Components: components marked with "use client" (and the libraries they import) that would otherwise be part of the route's client bundle.
next/dynamic is a combination of React's lazy() and Suspense. You give it a function that calls import(), and it returns a component. The first time that component renders, its chunk is fetched; until it arrives, a fallback is shown.
Basic Usage
// app/dashboard/revenue-section.tsx
"use client";
import dynamic from "next/dynamic";
const RevenueChart = dynamic(() => import("./revenue-chart"), {
loading: () => (
<div className="h-80 w-full animate-pulse rounded-lg bg-gray-100" />
),
});
export function RevenueSection({
data,
}: {
data: { month: string; total: number }[];
}) {
return (
<section>
<h2>Revenue</h2>
<RevenueChart data={data} />
</section>
);
}
// app/dashboard/revenue-chart.tsx
"use client";
// Imagine a heavy charting library imported here.
export default function RevenueChart({
data,
}: {
data: { month: string; total: number }[];
}) {
return (
<ul className="h-80">
{data.map((d) => (
<li key={d.month}>
{d.month}: {d.total}
</li>
))}
</ul>
);
}
What's happening:
import("./revenue-chart")tells the bundler to put that file, and everything it imports, in its own chunk.dynamic()wraps it in a component you render like any other. Props pass straight through, and TypeScript infers them from the imported component.loadingrenders while the chunk downloads. Give it the same dimensions as the real component, so the swap doesn't cause a layout shift.
There's a nuance here that surprises people. Because RevenueChart is rendered unconditionally, it's still server-side rendered: the HTML for the chart is in the initial response, and its chunk is loaded as part of hydrating the page. You've moved the code into a separate file, but users still download it on every page load. Splitting alone doesn't save much; the savings come from not rendering the component until it's needed.
Loading on Demand
The real benefit comes when the lazy component is rendered conditionally. Then the chunk isn't requested until the condition becomes true:
// app/notes/[id]/note-actions.tsx
"use client";
import dynamic from "next/dynamic";
import { useState } from "react";
const ShareDialog = dynamic(() => import("./share-dialog"), {
loading: () => <p role="status">Opening…</p>,
});
export function NoteActions({ noteId }: { noteId: string }) {
const [sharing, setSharing] = useState(false);
return (
<>
<button type="button" onClick={() => setSharing(true)}>
Share
</button>
{sharing && (
<ShareDialog noteId={noteId} onClose={() => setSharing(false)} />
)}
</>
);
}
Until someone clicks "Share", the dialog's code (and whatever it imports: a clipboard library, a user search component, permission controls) is never downloaded. Good candidates for this pattern:
- Modals, drawers, and dialogs
- Rich text and code editors
- Emoji and date pickers
- Image croppers and file upload widgets
- Admin-only or feature-flagged UI
- Anything behind a tab that isn't selected by default
Named Exports
import() resolves to the module object. If the component is a default export, dynamic() picks it up automatically. For a named export, return it from the promise:
// app/settings/page-client.tsx
"use client";
import dynamic from "next/dynamic";
const ColorPicker = dynamic(() =>
import("@/components/color-picker").then((mod) => mod.ColorPicker),
);
This is also how you lazy load a component from a package that only has named exports.
Skipping Server Rendering with ssr: false
Some components can't run on the server at all. They touch window or document at import time, rely on browser-only APIs like canvas or WebGL, or render something that will always differ between server and client. For those, turn off server rendering:
// app/stores/store-locator.tsx
"use client";
import dynamic from "next/dynamic";
const StoreMap = dynamic(() => import("./store-map"), {
ssr: false,
loading: () => (
<div
className="h-96 w-full rounded-lg bg-gray-100"
aria-label="Loading map"
/>
),
});
export function StoreLocator() {
return (
<div>
<h2>Find a store</h2>
<StoreMap />
</div>
);
}
With ssr: false, the server renders only the loading fallback. The real component is fetched and rendered after hydration, in the browser. A few rules:
ssr: falseonly works inside Client Components. Using it in a Server Component is an error. If your page is a Server Component, create a small"use client"wrapper (likeStoreLocatorabove) and render that from the page.- It's not a hydration-error fix. If a component renders differently on server and client because of something like the current time or
localStorage,ssr: falsehides the symptom at the cost of rendering nothing on the server. Fix the cause where you can. See fixing hydration mismatch errors. - No HTML means nothing for crawlers and nothing on first paint. Don't use it for content that matters for SEO or for anything above the fold.
- The fallback must hold the space. Since the component always appears after hydration, an unsized fallback guarantees a layout shift.
Lazy Loading Server Components
You can call dynamic() in a Server Component too, but it does less than you might expect. The Server Component itself isn't lazy loaded; it still renders on the server as part of the page. Only Client Components it renders get split out. And when a Server Component dynamically imports a Client Component directly, automatic code splitting is currently not supported.
In practice: do your lazy loading inside Client Components. For slow server work, the tool you want isn't next/dynamic but streaming with Suspense, covered in using loading.tsx and streaming UI.
Rules dynamic() Has to Follow
Next.js needs to find dynamic imports at build time to create chunks and preload them correctly. That imposes two rules:
- Call
dynamic()at the top level of a module, not inside a component or a function. LikeReact.lazy, calling it during render creates a new component on every render, which remounts it and throws away its state. - Write the import path as a literal string, inside the
dynamic()call.dynamic(() => import(path))with a variable, or a template string, can't be analyzed.
// Works
const Editor = dynamic(() => import("./editor"));
// Doesn't work: called during render
export function Page() {
const Editor = dynamic(() => import("./editor"));
return <Editor />;
}
If you need to choose between several components at runtime, declare each with its own dynamic() at the top level and pick between them in render:
// app/blocks/block-renderer.tsx
"use client";
import dynamic from "next/dynamic";
const blocks = {
chart: dynamic(() => import("./chart-block")),
video: dynamic(() => import("./video-block")),
quiz: dynamic(() => import("./quiz-block")),
};
export function BlockRenderer({ type }: { type: keyof typeof blocks }) {
const Block = blocks[type];
return <Block />;
}
Each block type gets its own chunk, and a page only downloads the blocks it actually renders.
Lazy Loading Libraries, Not Just Components
Sometimes the heavy part isn't a component but a library used inside an event handler. You don't need next/dynamic for that; a plain dynamic import() works:
// app/people/people-search.tsx
"use client";
import { useState } from "react";
type Person = { id: string; name: string; team: string };
export function PeopleSearch({ people }: { people: Person[] }) {
const [results, setResults] = useState<Person[]>(people);
async function handleSearch(query: string) {
if (!query) {
setResults(people);
return;
}
const { default: Fuse } = await import("fuse.js");
const fuse = new Fuse(people, { keys: ["name", "team"], threshold: 0.3 });
setResults(fuse.search(query).map((r) => r.item));
}
return (
<div>
<input
type="search"
placeholder="Search people"
aria-label="Search people"
onChange={(e) => handleSearch(e.target.value)}
/>
<ul>
{results.map((p) => (
<li key={p.id}>
{p.name} ({p.team})
</li>
))}
</ul>
</div>
);
}
fuse.js is only downloaded the first time the user types. After that, the module is cached by the browser and later import() calls resolve immediately. The same approach works for export libraries (CSV, PDF, Excel), confetti, and anything else used only in response to an action.
Loading When Content Scrolls Into View
For heavy components below the fold, like a comments section or a chart at the bottom of a long page, you can render the lazy component only when its placeholder approaches the viewport:
// components/when-visible.tsx
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
export function WhenVisible({
children,
placeholder,
rootMargin = "200px",
}: {
children: ReactNode;
placeholder: ReactNode;
rootMargin?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const node = ref.current;
if (!node || visible) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ rootMargin },
);
observer.observe(node);
return () => observer.disconnect();
}, [visible, rootMargin]);
return <div ref={ref}>{visible ? children : placeholder}</div>;
}
// app/blog/[slug]/comments-area.tsx
"use client";
import dynamic from "next/dynamic";
import { WhenVisible } from "@/components/when-visible";
const Comments = dynamic(() => import("./comments"), { ssr: false });
export function CommentsArea({ postId }: { postId: string }) {
const placeholder = <div className="h-64" aria-hidden="true" />;
return (
<WhenVisible placeholder={placeholder}>
<Comments postId={postId} />
</WhenVisible>
);
}
Comments isn't rendered until the placeholder is within 200px of the viewport, so its chunk isn't requested before then. Visitors who never scroll that far never download it. The fixed-height placeholder keeps the page from jumping when the real component arrives.
Preloading on Intent
Lazy loading on click has one drawback: the user waits for the chunk after clicking. You can hide most of that delay by starting the download when the user signals intent, such as hovering over or focusing the button, a few hundred milliseconds before the click:
// app/notes/[id]/note-actions-preload.tsx
"use client";
import dynamic from "next/dynamic";
import { useState } from "react";
const loadShareDialog = () => import("./share-dialog");
const ShareDialog = dynamic(loadShareDialog);
export function NoteActions({ noteId }: { noteId: string }) {
const [sharing, setSharing] = useState(false);
return (
<>
<button
type="button"
onMouseEnter={loadShareDialog}
onFocus={loadShareDialog}
onClick={() => setSharing(true)}
>
Share
</button>
{sharing && (
<ShareDialog noteId={noteId} onClose={() => setSharing(false)} />
)}
</>
);
}
Calling loadShareDialog() on hover starts fetching the chunk. When the click comes and ShareDialog renders, the module is already loaded (or nearly), because the browser's module cache deduplicates repeated imports of the same file. The import() inside dynamic() still has to be written so Next.js can see it; here it's the same function, defined once at the top level.
next/dynamic vs React.lazy
You can also use React.lazy with a Suspense boundary directly in Client Components:
"use client";
import { lazy, Suspense } from "react";
const Editor = lazy(() => import("./editor"));
export function EditorPanel() {
return (
<Suspense fallback={<p>Loading editor…</p>}>
<Editor />
</Suspense>
);
}
Both are server-rendered by default and both split the code. next/dynamic adds the loading option (no separate Suspense needed), ssr: false, and Next.js-specific preloading of the chunk during server rendering. React.lazy lets you share one Suspense boundary across several lazy components and coordinate their fallbacks. Either is fine; most Next.js codebases use next/dynamic for consistency.
When Lazy Loading Hurts
Lazy loading isn't free. Each lazy chunk is an extra network request, and the content behind it appears later. Avoid it when:
- The component is above the fold. Lazy loading the hero or the main navigation delays your Largest Contentful Paint and can cause layout shift. Load what users see first normally.
- The component is tiny. Splitting out a 2KB component costs more in request overhead than it saves.
- Everything is lazy. Ten small lazy components that all render on load create ten requests and a cascade of fallbacks swapping in. Group related UI into one chunk.
- Lazy components contain lazy components. Each level waits for the previous chunk before it can even request the next, creating a waterfall.
Two more things to plan for:
- Chunk loading can fail, for example on a flaky connection, or after a deploy when an open tab requests a chunk that no longer exists. The error is thrown during render and caught by the nearest error boundary, so make sure your
error.tsxoffers a retry or a reload. See custom error boundaries in Next.js. - Measure the effect. Use the bundle analyzer to confirm the component moved into its own chunk and that the main bundle actually shrank. Analyzing and reducing bundle size walks through it.
Conclusion
next/dynamic moves Client Components into separate chunks, but the savings only come when those components aren't rendered on initial load. Render lazy components conditionally (on click, when a tab opens, when content scrolls into view), give every loading fallback the real component's dimensions, and preload on hover or focus to hide the delay. Reserve ssr: false for genuinely browser-only components inside Client Components, use plain import() for libraries used in event handlers, and keep above-the-fold content out of lazy chunks entirely. Used that way, lazy loading keeps initial bundles small without making the page feel slower.


