
Next.js Lazy loading Client Components and libraries
Every component you ship, every library you import, every icon set you pull in — all of it has to travel down the wire before a browser can do anything with it. Next.js already does a lot of this work for you automatically: Server Components never reach the client bundle at all, and each route gets its own JavaScript chunk out of the box. But within a single route, it's entirely possible to ship megabytes of JavaScript for a modal, a chart, or a rich text editor that ninety percent of visitors never open.
Lazy loading is how you close that gap. Instead of bundling everything a page could possibly render, you defer the client-side code for anything that isn't needed immediately, and only fetch it when the user actually triggers it. Next.js gives you two built-in ways to do this, and once you understand the difference between them, you can be deliberate about what ships on first load versus what shows up later. This article walks through both, the specific patterns that tend to trip people up, and a set of judgment calls the docs don't spell out about when lazy loading actually helps and when it just adds latency for no reason.
Why This Is a Client-Side Concern, Not a Server One
It's worth being precise about what lazy loading in Next.js actually optimizes, because it's easy to conflate it with the automatic code splitting Next.js already does for you.
Every route in the App Router is automatically split into its own JavaScript chunk. Visiting /dashboard doesn't force the browser to download the code for /settings — that's just how the router works, with zero configuration on your part. Server Components go a step further: they render entirely on the server and never ship any component code to the browser at all, only the resulting HTML (and RSC payload) they produce.
Lazy loading targets what's left over: Client Components, and any JavaScript libraries you import inside them. These are the parts of your page that do get bundled and shipped to the browser, because they need to hydrate and run interactively. If you have a 'use client' component that renders a heavy dependency — a rich text editor, a charting library, a PDF viewer — that dependency ships in the client bundle whether or not the user ever interacts with it, unless you explicitly tell Next.js to defer it.
That's the entire point of this feature. You're not making your server do less work. You're making the browser download less JavaScript upfront, and pushing some of that download to a later point in time — ideally a point where the user is already waiting on something else, like a click, so the extra network round trip is invisible.
The Two Mechanisms Next.js Gives You
Next.js supports two ways to lazy load:
next/dynamic— a Next.js-specific wrapper that combinesReact.lazy()andSuspenseinto one API, with a few extra conveniences layered on top.React.lazy()withSuspensedirectly — the vanilla React primitive, without any Next.js-specific behavior.
In practice, almost everyone reaches for next/dynamic, because it gives you things plain React.lazy() doesn't: an ssr option to skip server-side prerendering entirely, a loading prop for a custom fallback without manually wrapping things in <Suspense>, and it works identically whether you're in the app directory or (if you're maintaining an older codebase) the pages directory, which matters if you're migrating incrementally.
I'll focus mostly on next/dynamic here since it's the practical default, and note where you'd want to reach for raw React.lazy() instead.
Step 1: Lazy Loading a Client Component
The simplest and most common case: you have a component that isn't needed on initial render, and you want its code to load only when it's actually rendered.
// app/page.js
"use client";
import { useState } from "react";
import dynamic from "next/dynamic";
const ComponentA = dynamic(() => import("../components/A"));
const ComponentB = dynamic(() => import("../components/B"));
const ComponentC = dynamic(() => import("../components/C"), { ssr: false });
export default function ClientComponentExample() {
const [showMore, setShowMore] = useState(false);
return (
<div>
{/* Loads immediately, but as its own separate client chunk */}
<ComponentA />
{/* Only fetched and rendered once showMore becomes true */}
{showMore && <ComponentB />}
<button onClick={() => setShowMore(!showMore)}>Toggle</button>
{/* Loads only in the browser, never during SSR */}
<ComponentC />
</div>
);
}
Notice that all three components in this example load differently, and that's the whole design space here:
ComponentAis wrapped indynamic()but rendered unconditionally. It still loads right away — the difference is that it's now split into its own chunk rather than being bundled inline with the parent. This matters when a component is large but isn't actually part of the critical path for first paint (think: below-the-fold content, a footer widget, anything the user will scroll to eventually but doesn't need instantly).ComponentBis the textbook lazy-loading case: it's gated behind conditional rendering, so its code genuinely doesn't get requested untilshowMoreflips totrue. This is where lazy loading earns its keep — a modal, an accordion panel, a settings drawer, anything hidden until a user action reveals it.ComponentCaddsssr: false, which is a separate axis entirely (more on that below).
One thing worth calling out that isn't obvious from the docs: dynamically importing a Client Component from a Server Component parent does not currently get the same automatic code-splitting treatment. If your goal is code splitting driven from a Server Component boundary, understand that this specific combination doesn't behave the way you might assume — test it and check your bundle output rather than assuming it "just works."
Step 2: Skipping Server-Side Rendering Entirely
By default, when you lazy load a Client Component with next/dynamic, React still prerenders it on the server — meaning the component's initial HTML shows up in the server response, and then hydrates on the client. That's usually what you want, since it avoids layout shift and gives the user something to look at immediately.
Sometimes, though, a component simply cannot run on the server. A library that reaches for window, document, or browser-only APIs during its initial render will throw if Next.js tries to execute it during SSR. For these, disable server rendering explicitly:
const ComponentC = dynamic(() => import("../components/C"), { ssr: false });
The detail that catches people off guard: ssr: false only works when it's used inside a Client Component. If you try to set ssr: false on a dynamic() call that lives inside a Server Component, Next.js will throw an error — the option simply isn't supported there, for reasons that become clearer once you understand the next section.
A practical pattern that follows from this: if you have a page that's otherwise a Server Component, but you need one ssr: false dynamic import somewhere in it, don't turn the whole page into a Client Component just to satisfy this constraint. Instead, extract a small Client Component wrapper whose only job is to hold that one dynamic import, and render that wrapper from your Server Component. You keep everything else server-rendered, and isolate the browser-only code to the smallest possible boundary.
// components/MapWrapper.tsx
"use client";
import dynamic from "next/dynamic";
const LeafletMap = dynamic(() => import("./LeafletMap"), { ssr: false });
export default function MapWrapper(props: { center: [number, number] }) {
return <LeafletMap {...props} />;
}
// app/page.tsx — stays a Server Component
import MapWrapper from "@/components/MapWrapper";
export default async function Page() {
const location = await getStoreLocation();
return <MapWrapper center={location} />;
}
This is a small structural discipline, but it's the difference between shipping one small client-only chunk for a map widget and accidentally converting an entire page's data-fetching logic into client-side code because you needed one ssr: false flag somewhere inside it.
Step 3: Dynamically Importing a Server Component
This is the part of the docs that's easiest to misread, because the behavior is genuinely different from the Client Component case, and the difference isn't cosmetic.
// app/page.js
import dynamic from "next/dynamic";
const ServerComponent = dynamic(() => import("../components/ServerComponent"));
export default function ServerComponentExample() {
return (
<div>
<ServerComponent />
</div>
);
}
When you wrap a Server Component in dynamic(), the Server Component itself is not what gets lazy-loaded — it renders on the server regardless, the same way it always would. What actually gets deferred is any Client Component that happens to be nested inside it. The dynamic import here is really acting on the subtree, not on the Server Component's own execution.
There's a secondary, less obvious benefit the docs mention only in passing: doing this also helps preload static assets — CSS in particular — associated with that subtree, ahead of when it's needed. If a nested Client Component pulls in its own stylesheet, wrapping the Server Component parent in dynamic() gives Next.js a hook to start fetching that CSS earlier than it otherwise would.
And as noted above: ssr: false is not allowed here at all. Since the Server Component always renders on the server no matter what, telling Next.js to skip SSR for it is a contradiction Next.js won't let you express — you'll get an explicit error if you try. If you need ssr: false behavior, that has to happen at the Client Component level, using the wrapper pattern from Step 2.
Step 4: Loading External Libraries On Demand
Lazy loading isn't limited to your own components — it applies just as well to third-party libraries you only need in response to a specific user action. The canonical example from the docs is a fuzzy-search library that only needs to exist once someone starts typing into a search box:
// app/page.js
"use client";
import { useState } from "react";
const names = ["Tim", "Joe", "Bel", "Lee"];
export default function Page() {
const [results, setResults] = useState();
return (
<div>
<input
type="text"
placeholder="Search"
onChange={async (e) => {
const { value } = e.currentTarget;
const Fuse = (await import("fuse.js")).default;
const fuse = new Fuse(names);
setResults(fuse.search(value));
}}
/>
<pre>Results: {JSON.stringify(results, null, 2)}</pre>
</div>
);
}
Notice this doesn't use next/dynamic at all — it's a raw import() call, awaited inside an event handler. That's a deliberate and useful distinction to internalize: next/dynamic is built for lazy loading components that need to render something (with fallback UI, SSR behavior, and so on), while a bare import() is the right tool for lazy loading a library you're going to call imperatively, with no rendering concerns attached to the import itself.
This pattern is worth reaching for specifically when a library is:
- Large relative to your typical bundle — a syntax highlighter, a full charting library, a PDF or spreadsheet generator, a rich diffing library.
- Rarely used per session — most visitors never trigger the feature it powers.
- Not needed for first paint — nothing about your initial render depends on it being available immediately.
If a library fails even one of those three tests, lazy loading it usually isn't worth the added complexity. A 3KB date formatting utility used on every page load gains you nothing by being deferred — you've just added an extra asynchronous step to your rendering logic for no measurable benefit.
Step 5: Giving Users Something to Look At While It Loads
Any lazy-loaded component takes a moment to fetch, parse, and execute — however brief. Left unhandled, that gap renders as nothing at all, which reads as a glitch rather than a loading state. next/dynamic lets you supply a fallback directly:
// app/page.js
"use client";
import dynamic from "next/dynamic";
const WithCustomLoading = dynamic(
() => import("../components/WithCustomLoading"),
{
loading: () => <p>Loading...</p>,
},
);
export default function Page() {
return (
<div>
<WithCustomLoading />
</div>
);
}
Treat the loading fallback the same way you'd treat a skeleton screen anywhere else in the app: match its dimensions to the real component as closely as you can, so it doesn't cause a layout jump the instant the real content swaps in. A one-line "Loading..." string is fine for a prototype, but for anything user-facing, a sized placeholder (a gray box roughly the size of the eventual chart, a skeleton row roughly the height of the eventual table) reads as intentional rather than broken.
Step 6: Lazy Loading Named Exports
import() resolves to a module namespace object, not the default export directly — which matters if the component you're targeting isn't a default export. You handle this by chaining .then() off the dynamic import to pull out the specific named export you want:
// components/hello.js
"use client";
export function Hello() {
return <p>Hello!</p>;
}
// app/page.js
import dynamic from "next/dynamic";
const ClientComponent = dynamic(() =>
import("../components/hello").then((mod) => mod.Hello),
);
This comes up more than you'd expect once you start lazy loading components from shared UI libraries, since a lot of component libraries intentionally avoid default exports (to keep tree-shaking and auto-import tooling predictable). Don't be thrown by the extra .then() — it's not doing anything exotic, it's just unwrapping the module object to get at the specific function or component you actually want.
Magic Comments: Telling the Bundler What to Do With an Import
Beyond next/dynamic itself, Next.js also supports a set of magic comments — specially formatted comments placed inside a dynamic import(), require(), require.resolve(), or new Worker() call — that give the bundler (Webpack or Turbopack) direct instructions about how to handle that specific import. These only apply to dynamic, expression-style imports; a static import x from 'y' at the top of a file can't carry these instructions.
webpackIgnore / turbopackIgnore tell the bundler to leave an import alone entirely rather than trying to bundle it — useful for modules that only exist, or only make sense, at runtime:
// Skip bundling entirely — resolved at runtime instead
const runtime = await import(/* webpackIgnore: true */ "runtime-module");
// Turbopack-specific equivalent
const plugin = await import(/* turbopackIgnore: true */ pluginPath);
// Works with require too
const mod = require(/* webpackIgnore: true */ "runtime-module");
turbopackOptional (Turbopack-only, with no Webpack equivalent) tells the bundler not to fail the build if a module can't be found at build time — deferring that failure to runtime instead, where it'll throw a MODULE_NOT_FOUND error only if the code path actually executes:
// Build succeeds even if './optional-feature' doesn't exist yet
// Only throws at runtime, and only if this line actually executes
const feature = await import(
/* turbopackOptional: true */ "./optional-feature"
);
These aren't things most projects need day-to-day, but they matter for specific structural patterns: plugin architectures where third-party or user-supplied modules may or may not be present, feature flags gating code that isn't installed in every environment, or incremental migrations where some files are still being written and shouldn't block a build. If you're building a plugin system on top of Next.js — a CMS with optional integrations, a dashboard with installable widgets — turbopackOptional is precisely the tool for letting a build succeed even when a given plugin's module isn't there.
Common Mistakes I See With Lazy Loading
Lazy loading things that are already small. The overhead of an extra network round trip and a loading state is not free. A component that renders three lines of text and one icon almost never benefits from being split into its own chunk — you're trading a marginal bundle-size win for a real, if small, latency cost and added code complexity. Reserve dynamic() for genuinely heavy dependencies, not everything with a 'use client' at the top.
Lazy loading above-the-fold, immediately-visible UI. If a component is visible the instant the page loads — your hero section, your primary navigation, the main content of the page — lazy loading it usually makes things worse, not better, because now the user is staring at a loading fallback for something that should have just been there. Save lazy loading for things that are conditionally rendered, below the fold, or gated behind interaction.
Forgetting to check what you actually saved. It's easy to add dynamic() around a component, feel good about it, and never verify the bundle actually shrank. Run ANALYZE=true npm run build with @next/bundle-analyzer configured, and confirm the component you lazy-loaded genuinely moved into its own chunk and dropped out of the main one. Sometimes a "lazy-loaded" component still ends up pulled into the main bundle because something else in the same file imports it eagerly elsewhere.
Assuming ssr: false fixes hydration mismatches. ssr: false is for components that literally cannot execute on the server (because they reach for browser globals during render), not a generic fix for hydration warnings. If a component can run fine on the server but produces slightly different output than the client (a random ID, a locale-dependent date format), the fix is a mount-based rendering pattern or suppressHydrationWarning, not disabling SSR for the whole component.
Not distinguishing next/dynamic from a raw import(). If you're lazy loading something to render, use next/dynamic — you get SSR control and a loading fallback for free. If you're lazy loading something to call, like a utility library inside an event handler, a plain await import() is simpler and doesn't need any of the component-oriented machinery next/dynamic provides.
When Lazy Loading Isn't the Right Tool
Lazy loading treats a symptom — a large client bundle — without addressing why the bundle got large in the first place. Before reaching for dynamic(), it's worth asking whether the real fix is:
- A lighter dependency. Sometimes the honest answer to "this library is huge" is switching to a smaller alternative, not deferring the huge one.
- Modular imports. If you're pulling in an entire library for one function (
import _ from 'lodash'for onechunkcall), fixing the import (import chunk from 'lodash/chunk') solves the bundle-size problem without adding any loading-state complexity at all. - Moving logic to the server. If a "heavy client library" is actually doing something that could just as well happen in a Server Component or a Server Function — formatting data, generating a static image, validating input — moving it server-side removes it from the client bundle entirely, which is strictly better than lazy loading it.
Lazy loading is the right answer when a genuinely client-only, genuinely heavy piece of UI is conditionally needed. It's the wrong answer when the actual fix is "this shouldn't have been this heavy, or this client-side, to begin with."
Key Takeaways
| Scenario | Tool | Notes |
|---|---|---|
| Defer a Client Component until rendered/triggered | next/dynamic | Prerendered (SSR'd) by default |
| Component can't run on the server at all | next/dynamic with ssr: false | Only valid inside a Client Component |
| Dynamically importing a Server Component | next/dynamic | Defers nested Client Components, not the Server Component itself; ssr: false not allowed |
| Loading a library to call imperatively | Raw await import() | No SSR/loading concerns — just resolve and use |
| Custom fallback while a component loads | loading option on dynamic() | Size it to match the real component to avoid layout shift |
| Importing a named (non-default) export | .then((mod) => mod.NamedExport) chained on the dynamic import | |
| Skip bundling an import entirely | /* webpackIgnore: true */ or /* turbopackIgnore: true */ | For runtime-only or externally resolved modules |
| Allow a missing optional module without failing the build | /* turbopackOptional: true */ | Turbopack only; throws at runtime if actually executed |
Used well, lazy loading is one of the cheapest performance wins available in a Next.js app — a few dynamic() calls around the right components can meaningfully shrink your initial JavaScript payload without touching your architecture. Used indiscriminately, it just adds loading spinners and network round trips to things that were never a problem. The judgment call is almost always the same one: is this component both heavy and conditionally needed? If yes, lazy load it. If either answer is no, leave it alone.


