When you import a 300KB charting library or a heavy text editor directly into a Client Component in Next.js 16, every user pays that download cost up front—even if the component sits behind a collapsed tab. Standard static ES imports force bundlers to stitch those packages into the initial page JavaScript. Your First Contentful Paint (FCP) and Interaction to Next Paint (INP) metrics tank, especially on mobile devices running throttled CPUs.
Next.js gives us next/dynamic to split these chunks away from the critical rendering path. Combined with the ssr: false option, you can completely isolate client-only, browser-heavy libraries. But splitting blindly can introduce network waterfalls and layout shifts if you don't measure what you're shipping.
Splitting Client Components with next/dynamic
In Next.js 16 running React 19, next/dynamic acts as a top-level loader that combines React's lazy() and Suspense under a unified API. It creates a separate bundle chunk that standard HTTP requests fetch only when the component mounts in the DOM.
Here's how to isolate a heavy analytics chart component that relies on browser APIs like Canvas and ResizeObserver:
'use client';
import dynamic from 'next/dynamic';
import { useState } from 'react';
const HeavyAnalyticsChart = dynamic(
() => import('./components/HeavyAnalyticsChart'),
{
loading: () => <div className="h-64 w-full bg-slate-100 animate-pulse rounded-lg" />,
ssr: false,
}
);
export default function AnalyticsDashboard() {
const [showMetrics, setShowMetrics] = useState(false);
return (
<div className="p-6 text-slate-900">
<h2 className="text-xl font-bold">Performance Overview</h2>
<button
onClick={() => setShowMetrics(!showMetrics)}
className="mt-4 px-4 py-2 bg-indigo-600 text-white rounded-md"
>
{showMetrics ? 'Hide Detailed Metrics' : 'Load Detailed Metrics'}
</button>
{showMetrics && (
<div className="mt-6">
<HeavyAnalyticsChart />
</div>
)}
</div>
);
}Passing ssr: false tells Next.js to skip server-side pre-rendering for this module entirely. The server renders only the skeleton provided in the loading fallback function. Once the HTML hits the browser and React hydrates the parent tree, the client issues a fetch request for the component's split JavaScript chunk.
The Server Component Trap with ssr: false
A common gotcha in Next.js 16 involves placing ssr: false directly inside a React Server Component (RSC). Server Components do not run in the browser client runtime; they render exclusively on the server or during build time. If you call dynamic(() => import(...), { ssr: false }) inside a file without the 'use client' directive, Next.js throws an explicit build error:
Error: ssr: false is not supported with Server Components. Move this dynamic import into a Client Component.
To fix this, keep your Server Components clean and pass server-fetched data as props into a dedicated Client Component wrapper where next/dynamic handles the deferred execution.
Measuring the Payload with @next/bundle-analyzer
Refactoring code without measuring bundle output is guessing. You need to verify that Next.js actually extracted the heavy dependency into its own standalone chunk instead of leaking it back into main-app-[hash].js or the page's shared chunk.
Install the official bundle analyzer package:
npm install @next/bundle-analyzerThen update your next.config.mjs configuration to wrap your export with the analyzer plugin when an environment variable is set:
// next.config.mjs
import bundleAnalyzer from '@next/bundle-analyzer';
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
export default withBundleAnalyzer(nextConfig);Run your production build with analysis turned on:
ANALYZE=true npm run buildNext.js generates two interactive HTML reports in your .next/analyze directory: one for the browser bundles and one for the Node.js server bundles. Open client.html in your browser.
Reading the Numbers
Look for three values on every parsed node in the visual map:
- Stat size: The raw, uncompressed source code before minification.
- Parsed size: The minified code output by SWC. This is what the browser parses and compiles.
- Gzipped size: The wire size sent across the network.
On a recent dashboard refactor using Next.js 16 and React 19, direct static imports of Monaco Editor and Recharts resulted in an initial JavaScript payload of 480KB (gzipped). By wrapping both modules in next/dynamic with ssr: false and rendering them conditionally upon tab selection, the initial client bundle dropped to 135KB. On a throttled 4G network test on a mid-range mobile device, Total Blocking Time dropped from 380ms down to 40ms, and INP improved from 290ms to 35ms.
Trade-offs: Layout Shifts and Request Waterfalls
Code splitting is not free. When you defer component loading, you trade initial bundle size for runtime network roundtrips and potential visual shifts.
1. Cumulative Layout Shift (CLS)
If your loading fallback has a height of 0px, loading a 400px chart component will push layout elements down the viewport when the JS chunk arrives and executes. Always give your loading fallback matching layout dimensions using explicit Tailwind height classes or CSS aspect ratio properties.
2. Network Waterfalls
If Component A dynamically imports Component B, and Component B dynamically imports Component C, you create a sequential chain of network fetches: render A -> download B -> render B -> download C. The user sits waiting through three roundtrips. Keep dynamic imports shallow. Top-level dynamic imports at feature boundaries (tabs, modals, drawer panels, below-the-fold widgets) provide maximum bundle reduction with minimal latency penalty.
3. Event-Driven Dynamic Imports
Sometimes you don't even need next/dynamic. For action-based payloads like exporting PDF files or downloading CSV datasets, standard dynamic ES imports inside event handlers work better:
'use client';
export default function ExportButton({ data }) {
const handleExport = async () => {
// Load xlsx library only when user clicks export
const XLSX = await import('xlsx');
const worksheet = XLSX.utils.json_to_sheet(data);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Data');
XLSX.writeFile(workbook, 'export.xlsx');
};
return (
<button
onClick={handleExport}
className="px-4 py-2 bg-emerald-600 text-white rounded-md hover:bg-emerald-700"
>
Export to Excel
</button>
);
}In this pattern, zero code for the spreadsheet generation library is included in the initial render or pre-rendered HTML. The browser fetches the 150KB library chunk only when the user clicks the button, keeping your page lightweight and responsive.









