Dynamic Imports, Code Splitting, and Lazy Hydration
Defer non-critical components with next/dynamic and tune loading to cut time-to-interactive.
Dynamic Imports, Code Splitting, and Lazy Hydration is a free Next.js 15 Fullstack (App Router + Server Actions) lesson on CoddyKit — lesson 4 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Next.js 15 Fullstack (App Router + Server Actions) learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Why Code Splitting Matters in Next.js 15
Modern Next.js applications can grow large fast. Without code splitting, the browser downloads every component and dependency on the first page load — even code the user may never need.
Next.js 15 splits your bundle automatically at the route level using the App Router. But route-level splitting alone is not enough. Consider these common performance killers:
- A rich text editor loaded on every page but only used in the admin dashboard
- A heavy chart library rendered below the fold
- A modal or drawer that is never opened by most visitors
For these cases you need component-level code splitting via next/dynamic and lazy hydration strategies. Together they reduce your initial JavaScript payload, improve Time-to-Interactive (TTI), and raise your Core Web Vitals scores.
Introducing next/dynamic
next/dynamic is Next.js's wrapper around React.lazy with additional features tailored for SSR. It returns a component that is loaded on demand — the JavaScript for that component is placed in a separate chunk and only fetched when the component is about to render.
Basic usage is straightforward:
- Import
dynamicfrom'next/dynamic' - Pass a factory function that returns a dynamic
import() - Optionally pass a
loadingfallback component shown while the chunk downloads
The resulting component can be used exactly like any normal React component in your JSX.
'use client';
import dynamic from 'next/dynamic';
// The HeavyEditor chunk is NOT included in the initial bundle.
// It is fetched only when <HeavyEditor /> is first rendered.
const HeavyEditor = dynamic(
() => import('@/components/HeavyEditor'),
{
loading: () => <p>Loading editor…</p>,
}
);
export default function AdminPage() {
return (
<main>
<h1>Admin Dashboard</h1>
<HeavyEditor />
</main>
);
}Disabling SSR for Client-Only Components
Some components depend on browser APIs (window, document, localStorage) and cannot run on the server at all. Attempting SSR on these causes hydration mismatches or runtime errors.
next/dynamic supports an ssr: false option that tells Next.js to skip server rendering entirely for that component. The placeholder (or nothing) is sent in the HTML, and the real component is mounted only in the browser.
Common use cases for ssr: false:
- Canvas / WebGL renderers
- Browser-only animation libraries (e.g. GSAP ScrollTrigger)
- Components that read
window.matchMediaon mount - Third-party widgets that inject into
document.body
'use client';
import dynamic from 'next/dynamic';
// This component uses `window` and `document` internally.
// ssr: false prevents Next.js from attempting to render it on the server.
const ConfettiBlast = dynamic(
() => import('@/components/ConfettiBlast'),
{
ssr: false,
loading: () => null, // render nothing until JS loads
}
);
export default function CelebrationBanner() {
return (
<section>
<h2>You did it! 🎉</h2>
<ConfettiBlast particleCount={200} />
</section>
);
}Named Exports and next/dynamic
By default next/dynamic expects the imported module to have a default export. When you need to dynamically import a named export, you must extract it inside the factory function.
This is done by returning the named export from the async factory, effectively making it the default for the dynamic wrapper:
'use client';
import dynamic from 'next/dynamic';
// Module exports: { LineChart, BarChart, PieChart }
// We only need LineChart — extract it inside the factory.
const LineChart = dynamic(
() =>
import('@/components/charts/ChartLibrary').then(
(mod) => mod.LineChart
),
{ loading: () => <div className="h-64 animate-pulse bg-gray-100" /> }
);
interface SalesChartProps {
data: { month: string; revenue: number }[];
}
export default function SalesChart({ data }: SalesChartProps) {
return <LineChart data={data} width={600} height={300} />;
}Conditional Dynamic Imports — Load on Interaction
The most powerful pattern is to defer a component until the user actually needs it — triggered by a click, hover, or scroll event. This avoids loading the chunk even during idle time.
The approach uses React state to conditionally render the dynamically imported component. Until the user interacts, the chunk is never requested. Once they click (or trigger the condition), React renders the dynamic component and the browser fetches the chunk on demand.
This pattern is ideal for:
- Modals and drawers opened by a button
- Settings panels
- Video players that start on play
- Comment sections below a long article
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';
const FeedbackModal = dynamic(
() => import('@/components/FeedbackModal'),
{ loading: () => <p>Opening…</p> }
);
export default function FeedbackButton() {
const [open, setOpen] = useState(false);
return (
<>
<button
onClick={() => setOpen(true)}
className="btn-primary"
>
Leave Feedback
</button>
{/* FeedbackModal chunk is only fetched after the first click */}
{open && (
<FeedbackModal onClose={() => setOpen(false)} />
)}
</>
);
}Lazy Hydration with 'use client' Boundaries
In the App Router, components are React Server Components (RSC) by default. Only components marked 'use client' ship JavaScript to the browser and hydrate.
This means you get free lazy hydration by keeping components as Server Components whenever possible. The server renders the HTML; no hydration cost is paid at all.
When you do need interactivity, push the 'use client' boundary as far down the tree as possible — to the exact leaf component that needs event handlers or state. Parent layout and wrapper components stay as RSC and add zero client JS.
- Bad: Mark the entire page layout as
'use client'because one button needsonClick - Good: Extract only the button into its own
'use client'component; the rest stays RSC
// app/products/[id]/page.tsx — Server Component (no 'use client')
import { getProduct } from '@/lib/db';
import AddToCartButton from '@/components/AddToCartButton'; // 'use client'
interface PageProps {
params: { id: string };
}
export default async function ProductPage({ params }: PageProps) {
// Runs on the server — zero client JS for this component
const product = await getProduct(params.id);
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p className="price">${product.price}</p>
{/* Only this leaf component ships client JS */}
<AddToCartButton productId={product.id} />
</article>
);
}Intersection Observer — Hydrate on Scroll
Components below the fold do not need to hydrate immediately. A popular pattern is to use the Intersection Observer API to defer mounting (and therefore hydrating) a component until it scrolls into the viewport.
You can combine this with next/dynamic to achieve true lazy hydration: the JS chunk is requested only when the component enters the viewport, and mounting happens right after the chunk arrives.
Libraries like react-intersection-observer make this ergonomic. The pattern below mounts CommentsSection only once the user scrolls near it:
'use client';
import { useRef, useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
const CommentsSection = dynamic(
() => import('@/components/CommentsSection'),
{ loading: () => <div className="h-32 animate-pulse bg-gray-100" /> }
);
export default function ArticlePage() {
const sentinelRef = useRef<HTMLDivElement>(null);
const [showComments, setShowComments] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setShowComments(true);
observer.disconnect(); // hydrate once, then stop observing
}
},
{ rootMargin: '200px' } // start loading 200px before viewport
);
if (sentinelRef.current) observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, []);
return (
<article>
<h1>A Very Long Article</h1>
<p>…article content…</p>
{/* Sentinel sits at the bottom; triggers chunk fetch when visible */}
<div ref={sentinelRef} />
{showComments && <CommentsSection />}
</article>
);
}Preloading Chunks on Hover
Waiting until a click to start downloading a chunk adds latency: the user sees a spinner while the network request completes. A smarter UX is to preload the chunk on hover — typically 100–300 ms before the user clicks, which is often enough time for the chunk to arrive.
next/dynamic exposes a static .preload() method on the returned component. Calling it triggers the dynamic import without rendering anything, priming the browser cache so that when the component mounts it is nearly instant.
'use client';
import dynamic from 'next/dynamic';
const ShareDialog = dynamic(
() => import('@/components/ShareDialog')
);
import { useState } from 'react';
export default function ShareButton() {
const [open, setOpen] = useState(false);
return (
<>
<button
// Preload the chunk as soon as the user hovers
onMouseEnter={() => ShareDialog.preload()}
// Open (render) on click — chunk is likely already cached
onClick={() => setOpen(true)}
className="btn-secondary"
>
Share
</button>
{open && <ShareDialog onClose={() => setOpen(false)} />}
</>
);
}Analyzing Bundle Size with @next/bundle-analyzer
Before you can optimise bundle size you need to see it. The @next/bundle-analyzer package wraps Webpack Bundle Analyzer and generates a visual treemap of every module in your build.
Setup is two steps:
- Install:
npm install --save-dev @next/bundle-analyzer - Wrap your Next.js config with the analyzer
Run ANALYZE=true next build and two browser tabs open — one for the client bundle, one for the server bundle. Look for:
- Unexpectedly large modules in the initial chunk
- Duplicate libraries (e.g. two versions of
date-fns) - Libraries that should be dynamically imported but appear in the main chunk
// next.config.ts
import type { NextConfig } from 'next';
import bundleAnalyzer from '@next/bundle-analyzer';
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
openAnalyzer: true,
});
const nextConfig: NextConfig = {
// your existing config…
experimental: {
optimizePackageImports: [
// Tell Next.js to tree-shake these icon/component libraries
// so only the icons you actually import are bundled
'@heroicons/react',
'lucide-react',
'@radix-ui/react-icons',
],
},
};
export default withBundleAnalyzer(nextConfig);Dynamic Imports in Server Components
You can also use dynamic import() inside Server Components — not via next/dynamic, but via plain ES dynamic import. The result is still server-rendered; the benefit is conditional loading on the server: you avoid importing heavy modules when they are not needed for a given request.
A typical case is locale-specific data, feature-flag-gated renderers, or optional plugins loaded based on configuration:
// app/report/page.tsx — Server Component
import type { NextPage } from 'next';
interface ReportPageProps {
searchParams: { format?: string };
}
const ReportPage: NextPage<ReportPageProps> = async ({ searchParams }) => {
const format = searchParams.format ?? 'html';
if (format === 'pdf') {
// Heavy PDF renderer is only imported when the query param is 'pdf'.
// It never reaches the browser — this is pure server-side splitting.
const { renderPDF } = await import('@/lib/pdf-renderer');
const pdfBuffer = await renderPDF({ title: 'Q2 Report' });
return new Response(pdfBuffer, {
headers: { 'Content-Type': 'application/pdf' },
}) as unknown as JSX.Element;
}
// Default: lightweight HTML version
const { ReportView } = await import('@/components/ReportView');
return <ReportView />;
};
export default ReportPage;Measuring Impact — Core Web Vitals
Code splitting and lazy hydration directly improve two Core Web Vitals:
- LCP (Largest Contentful Paint) — less blocking JS means the browser paints the largest element sooner
- INP (Interaction to Next Paint) — smaller main-thread work during load means the page responds faster to the first user tap
Measure before and after your changes using:
next build && next start+ Chrome DevTools Lighthouse (local, reproducible)web-vitalsnpm package + theuseReportWebVitalshook fromnext/navigationto log metrics to your analytics backend in production- Vercel Speed Insights or Google Search Console for real-user data
A common result after switching a heavy component to next/dynamic: the initial JS payload drops by 30–60 KB (gzipped), translating to 200–500 ms faster TTI on a median mobile connection.
Knowledge Check: When to Use ssr: false
You are integrating a third-party mapping library that accesses window.navigator.geolocation synchronously during module initialisation. Which next/dynamic configuration is correct and why?
Recap — Dynamic Imports and Lazy Hydration
In this lesson you learned how to cut time-to-interactive in Next.js 15 App Router applications by deferring JavaScript that is not needed upfront.
Key takeaways:
next/dynamicsplits a component into a separate chunk fetched on demand — reducing the initial JS payloadssr: falseskips server rendering for components that rely on browser-only APIs, preventing runtime errors- Named exports are handled by extracting them inside the factory:
import(…).then(mod => mod.Named) - Conditional rendering (
{open && <Modal />}) defers the chunk fetch until the first render of the component Component.preload()on hover primes the cache before the user clicks, hiding network latency- Keeping
'use client'boundaries as narrow as possible gives you free lazy hydration for the RSC parts of your tree - Intersection Observer enables viewport-triggered hydration for below-the-fold content
@next/bundle-analyzeranduseReportWebVitalslet you measure the real impact of these optimisations
Apply these techniques to the heaviest components in your bundle first — editors, charts, maps, and media players — for the greatest TTI gains.
Frequently asked questions
Is the “Dynamic Imports, Code Splitting, and Lazy Hydration” lesson free?
Yes — the full text of “Dynamic Imports, Code Splitting, and Lazy Hydration” is free to read here on the web, and the Next.js 15 Fullstack (App Router + Server Actions) course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Next.js 15 Fullstack (App Router + Server Actions) course, upgrade to CoddyKit PRO.
What will I learn in “Dynamic Imports, Code Splitting, and Lazy Hydration”?
Defer non-critical components with next/dynamic and tune loading to cut time-to-interactive. You practise Next.js 15 Fullstack (App Router + Server Actions) with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start Next.js 15 Fullstack (App Router + Server Actions)?
No prior experience is required. Next.js 15 Fullstack (App Router + Server Actions) on CoddyKit is structured for beginners through advanced learners; this is — lesson 4 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Dynamic Imports, Code Splitting, and Lazy Hydration” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this Next.js 15 Fullstack (App Router + Server Actions) lesson?
Yes. Every Next.js 15 Fullstack (App Router + Server Actions) lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Analyzing and Shrinking the Client Bundle
- Turbopack and Compiler Configuration Deep Dive
- Module Boundaries with server-only and client-only
- Dynamic Imports, Code Splitting, and Lazy Hydration