Sitemap

Here’s What Actually Changed

4 min readSep 4, 2026

--

I copied a two-year-old dynamic-import snippet into a Next.js 16 app and the build died on the spot:

Error: `ssr: false` is not allowed with `next/dynamic` in Server Components.
Please move it into a Client Component.

That error didn’t exist when most of the “optimize your Next.js bundle” posts were written. The App Router changed the rules, and the snippet everyone still copies — dynamic(() => import('./Heavy'), { ssr: false }) dropped into a page — is now a hard failure, not a warning. So before repeating the old advice, let me show what breaks, what the fix is, and what the split actually buys you in bytes. Numbers are from Next.js 16.3.0 on Node 24, Turbopack build.

The snippet that no longer builds

Here’s the classic version. A page (a Server Component by default in the App Router) lazy-loads a heavy chart and disables SSR because the chart touches window:

// app/page.tsx  — Server Component by default
import dynamic from "next/dynamic";
const HeavyChart = dynamic(() => import("./_components/HeavyChart"), {
ssr: false,
loading: () => <p>Loading chart…</p>,
});
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1>
<HeavyChart />
</main>
);
}

Run next build and you get the error above. next build exits 1. In the old pages/ router this was the recommended pattern. In the App Router ssr: false inside a Server Component is disallowed, because there's no client boundary for Next to bail out to — the server has nowhere to render nothing.

The fix: ssr: false lives in a Client Component now

Move the dynamic call behind a "use client" boundary. That's the whole fix:

// app/page.tsx
"use client";
import { useState } from "react";
import dynamic from "next/dynamic";
const HeavyChart = dynamic(() => import("./_components/HeavyChart"), {
ssr: false,
loading: () => <p>Loading chart…</p>,
});
export default function DashboardPage() {
const [show, setShow] = useState(false);
return (
<main>
<h1>Dashboard</h1>
<button onClick={() => setShow(true)}>Show chart</button>
{show && <HeavyChart />}
</main>
);
}

And the component it loads — a real client component using chart.js, which is exactly the kind of browser-only dependency you don’t want in your first paint:

// app/_components/HeavyChart.tsx
"use client";
import { useEffect, useRef } from "react";
import { Chart, registerables } from "chart.js";
Chart.register(...registerables);export default function HeavyChart() {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!ref.current) return;
const chart = new Chart(ref.current, {
type: "line",
data: {
labels: Array.from({ length: 12 }, (_, i) => `M${i + 1}`),
datasets: [{ label: "Revenue", data: [12, 19, 3, 5, 2, 3, 9, 7, 11, 4, 6, 8] }],
},
});
return () => chart.destroy();
}, []);
return <canvas ref={ref} height={120} />;
}

This builds clean. The chart never touches the server, chart.js gets its own chunk, and it only downloads when someone clicks the button.

Get @rnab’s stories in your inbox

Join Medium for free to get updates from this writer.

If you don’t need to disable SSR — say the component is heavy but server-safe — you can keep the dynamic import in a Server Component. It’s specifically the ssr: false flag that requires the client boundary. That distinction is easy to miss because the old mental model was "dynamic import = client thing."

What the split is actually worth

“Lazy loading improves performance” is the kind of claim that gets repeated without a number attached, so I measured it. Same app, two builds: one imports HeavyChart statically, one imports it with next/dynamic. I summed the JavaScript referenced in the initial HTML each build shipped.

  • Static import: 764,179 bytes of initial JS. The chart.js chunk (about 200 KB) is loaded on first paint whether or not the user ever opens the chart.
  • Dynamic import: 568,812 bytes of initial JS. The chart.js chunk is deferred into a separate file that loads on click.

That’s 195,367 bytes — roughly 191 KB — pulled out of the initial download, a 25.6% smaller first-load bundle for this page. On a phone on a bad connection, that’s the difference between shipping a charting library to everyone and shipping it only to the people who actually look at a chart.

One thing worth being honest about: dynamic imports don’t reduce your total JavaScript. Both builds ship the same code overall. You’re moving bytes out of the critical path, not deleting them. If the user does click the button, they still download that 191 KB — just later, and only if they need it. That’s the whole trade: defer, don’t shrink.

Where this pays off and where it doesn’t

Split things that are heavy and conditional. A charting library behind a tab, a rich text editor that opens in a modal, a map that renders after a “show location” click — all good. The chunk sits on disk until the interaction happens.

Splitting a component that renders immediately on every page load is worse than useless. You’ve added a network round-trip and a loading flash to fetch code you were always going to need. I’ve seen dashboards where someone wrapped every widget in dynamic() and made the page slower, because now the browser makes ten sequential requests for chunks it needs right away.

The quick test I use: would this code run in the first second for most users? If yes, leave it static. If it’s gated behind a click, a route, or a viewport that most people never reach, split it.

The part people skip

Check what’s in your initial bundle before you start splitting. On a Turbopack build you can’t eyeball it from the build summary the way the old webpack output printed per-route First Load JS, so I sum the chunk files referenced in the served HTML directly — which is exactly how I got the numbers above. Split the two or three biggest offenders that are also conditional, remeasure, and stop. Blanket-splitting everything is how you end up slower than when you started.

And if you’re migrating older code: any dynamic(..., { ssr: false }) sitting in an App Router page is now a build error, not a runtime quirk. Grep for it before you upgrade to 16.

--

--

@rnab
@rnab

Written by @rnab

Typescript, Devops, Kubernetes, AWS, AI/ML, Algo Trading