Using dynamic import() in React Server Components

Symptom: a page that worked fine under the Next.js Pages Router throws a build-time error immediately after migrating to the App Router, or after moving a component one level up into a Server Component during a refactor:

⨯ × `ssr: false` is not allowed with `next/dynamic` in Server Components.
Please move it into a client component.

  ./app/dashboard/page.tsx
  Import trace:
    ./app/dashboard/page.tsx

The component in question is nothing exotic — a chart widget, a rich-text editor, a map — the kind of dependency that has always been loaded with { ssr: false } because it touches window or a browser-only API during initialization. The call itself hasn’t changed. What changed is which kind of file it now lives in.

Root cause: Server Components have no client runtime to opt out of

next/dynamic’s ssr: false option tells the framework “render this component on the client only, skip it during the server pass.” That instruction is coherent inside a Client Component — a module marked with the 'use client' directive, which already ships a JavaScript bundle to the browser and therefore has a client-side render pass to opt in or out of. It is incoherent inside a Server Component, which is the App Router’s default for every file that doesn’t carry 'use client'. A Server Component’s output is HTML (or an RSC payload) generated entirely on the server; there is no client-side render of that component to disable, so the framework fails the build rather than silently producing behavior nobody asked for.

This is the same server/client boundary problem covered at a broader level in code splitting for SSR and React Server Components — this page walks through the exact fix for the next/dynamic case, one layer down from that architectural overview. It’s also a direct consequence of how the parent route-based code splitting pillar frames chunk boundaries: a chunk boundary in the App Router is no longer just “does this code load lazily,” it’s “which runtime does this code belong to at all.”

A useful mental model: in the Pages Router, every component you write is implicitly a Client Component with respect to this rule, because the Pages Router has no server/client split — the whole tree hydrates. ssr: false has always been legal there. The App Router adds a real split and enforces it, which is why unmodified code that worked for years suddenly fails the moment it’s placed inside a file without 'use client'.

It helps to be precise about what “Server Component” means here, because the term is easy to conflate with server-side rendering in general. Every framework in the route-based code splitting space has done SSR for years without an RSC-style split — a Vue 3 or classic Next.js Pages Router app renders its whole tree on the server and then hydrates the whole tree on the client, and every component in that tree ships JavaScript regardless of whether the server rendered it. RSC changes that default: a Server Component’s JavaScript source never crosses the network at all, only its rendered output (or, for RSC payloads sent to a Client Component, a serialized description of its position in the tree). next/dynamic’s ssr: false predates this split and was written for a world where “the client” and “everything that isn’t the initial HTML” were the same set. Inside a Server Component, they no longer are — there is no bundle for that component to skip rendering in, so the constraint the option is trying to express doesn’t have a target.

Exact fix: extract a Client Component wrapper

The fix is not to slap 'use client' on the Server Component that imports the widget — that would convert everything else in that file’s module graph into client-shipped JavaScript too, undoing the exact bundle-size win Server Components exist to provide. Instead, isolate the dynamic import behind a small dedicated wrapper.

Before (fails to build):

// app/dashboard/page.tsx — Server Component (no directive at the top)
import dynamic from 'next/dynamic';

const RevenueChart = dynamic(() => import('./RevenueChart'), {
  ssr: false, // ← build error: not allowed here
});

export default function DashboardPage() {
  return (
    <main>
      <h1>Revenue</h1>
      <RevenueChart />
    </main>
  );
}

After (builds and renders correctly):

// app/dashboard/revenue-chart-loader.tsx — Client Component wrapper
'use client';

import dynamic from 'next/dynamic';

const RevenueChart = dynamic(() => import('./RevenueChart'), {
  ssr: false, // legal here — this whole module already runs client-side only
  loading: () => <div className="chart-placeholder" aria-busy="true" style={{ height: 320 }} />,
});

export default function RevenueChartLoader() {
  return <RevenueChart />;
}
// app/dashboard/page.tsx — Server Component, now importing the wrapper instead
import RevenueChartLoader from './revenue-chart-loader';

export default function DashboardPage() {
  return (
    <main>
      <h1>Revenue</h1>
      <RevenueChartLoader />
    </main>
  );
}

DashboardPage stays a Server Component. It never runs in the browser, never ships its own JavaScript, and can freely import server-only utilities (a database client, a filesystem read) elsewhere in the same file without those leaking into the client bundle. Only RevenueChartLoader and everything it imports — next/dynamic, RevenueChart, and RevenueChart’s own dependencies — become part of the client graph, which is exactly the scope the original { ssr: false } call was trying to express.

If the widget needs props from server-fetched data, pass them through explicitly rather than fetching inside the Client Component:

// app/dashboard/page.tsx — passing server data into the client wrapper
import RevenueChartLoader from './revenue-chart-loader';
import { getRevenueSeries } from '@/lib/data/revenue';

export default async function DashboardPage() {
  const series = await getRevenueSeries(); // runs on the server, never bundled for the client
  return (
    <main>
      <h1>Revenue</h1>
      <RevenueChartLoader series={series} />
    </main>
  );
}

Step-by-step verification

  1. Rebuild and confirm the error is gone. Run next build and check that the ssr: false is not allowed error no longer appears in the output.
  2. Check the server HTML for the placeholder, not the widget. Run curl -s https://localhost:3000/dashboard | grep -o 'chart-placeholder' (or view-source in the browser) — you should see the loading fallback markup, not the chart’s own DOM, because ssr: false means the widget itself never renders on the server.
  3. Confirm the chunk fetches client-side. Open DevTools Network, filter by JS, and reload. You should see a separate request for the chunk containing RevenueChart, fired after the initial page script runs, not bundled into the main entry.
  4. Diff the client bundle for the Server Component’s other imports. Run your bundle analyzer against the client build and confirm none of DashboardPage’s server-only imports (data-fetching utilities, server SDKs) appear in the client graph. Their presence would indicate the 'use client' boundary was drawn too high up the tree.
  5. Exercise the no-JavaScript path. Disable JavaScript in DevTools and reload the route. The rest of the page should render from the server HTML; only the { ssr: false } widget’s fallback should be visible, confirming the boundary isolated exactly the component you intended.

Edge cases and gotchas

Gotcha 1 — the wrapper still needs its own Suspense boundary

Extracting a Client Component wrapper fixes the build error but does not, by itself, give you a loading state while the chunk fetches — loading inside next/dynamic’s options only covers the client-side fetch of the lazy component’s own chunk, not the wrapper’s own mount. For a smoother transition, wrap the call site in <Suspense> as well:

// app/dashboard/page.tsx — adding a Suspense boundary around the wrapper
import { Suspense } from 'react';
import RevenueChartLoader from './revenue-chart-loader';

export default function DashboardPage() {
  return (
    <main>
      <h1>Revenue</h1>
      <Suspense fallback={<div className="chart-placeholder" aria-busy="true" style={{ height: 320 }} />}>
        <RevenueChartLoader />
      </Suspense>
    </main>
  );
}

This pairs well with the selective-hydration behavior covered in the companion guide streaming SSR and selective hydration with code splitting: each Suspense-wrapped widget hydrates independently rather than blocking on the slowest one. The pattern echoes the client-only Suspense boundaries described in how to implement React.lazy with route transitions, except here the boundary also has to account for a component that intentionally never renders on the server at all.

Gotcha 2 — third-party components that call browser APIs at import time, not render time

Some libraries (older map SDKs, certain rich-text editors) reference window or document at module scope, outside any component function. Even inside a correctly placed Client Component, import()-ing such a library during a server pass that hasn’t yet been isolated to ssr: false will throw ReferenceError: window is not defined before your component ever mounts. The wrapper pattern above still fixes this, because the dynamic import itself only executes client-side — but if the same library is imported statically anywhere in the Server Component’s module graph (even unused), the error resurfaces. Search for stray static imports of the library across the file and its co-located helpers.

Gotcha 3 — layout.tsx boundaries hide which components are actually server-rendered

Because Server and Client Component status propagates from a file’s own directive plus its import chain, a component can end up client-rendered simply because a distant ancestor layout.tsx is marked 'use client' — often for an unrelated reason like a theme context provider. Audit layout.tsx files first when a next/dynamic call behaves unexpectedly; the fix is frequently to move the client-only context provider down to wrap only the subtree that needs it, via implementing route-level code splitting in SPAs patterns adapted to the App Router’s file-based boundaries, rather than adjusting the widget’s own import.

This class of mistake is worth treating as a checklist item rather than a one-off fix, because it recurs every time a new context provider, analytics wrapper, or A/B-testing hook gets added near the root layout. Each of those additions is a candidate to accidentally widen the client boundary for every route beneath it. A quick guard: before merging a change to any layout.tsx, grep the diff for 'use client' and, if present, confirm the file’s only responsibility is the client-only concern it was added for — not also rendering static, server-renderable markup that would be cheaper to leave as a Server Component one level down.

Gotcha 4 — dynamic() calls inside a shared component library

Component libraries shared across a Pages Router app and an App Router app are a common source of confusion, because the same exported component compiles differently depending on which router imports it. If a shared library exports a component that calls next/dynamic with ssr: false at its top level, that export works fine when imported from the Pages Router (implicitly client) but fails the moment an App Router page imports it directly into a Server Component. The fix is the same wrapper pattern shown above, applied inside the shared library itself: export both a server-safe version (no ssr: false) and a pre-wrapped, 'use client'-marked version, and document which one App Router consumers should import.

FAQ

Why does ssr: false throw an error only in some files and not others?

It throws in any file that is a Server Component — meaning it has no ‘use client’ directive and no client-boundary ancestor forcing it into the client graph. It does not throw in a file marked ‘use client’, because that file already runs exclusively in the browser, where disabling server rendering for a sub-component is a meaningful, supported operation.

Can I just add ‘use client’ to the top of my Server Component to fix the error?

That fixes the build error but usually creates a worse problem: every component that file imports, including ones that only render static markup, now ships to the browser as JavaScript. Prefer extracting a small wrapper component that isolates only the dynamic-import call, keeping the rest of the tree as Server Components.

Does plain dynamic import() without next/dynamic work inside a Server Component?

Yes, and it behaves like ordinary server-side code splitting — it does not have an ssr option because the concept does not apply; the module executes on the server either way. Use bare import() in a Server Component to split its own dependency graph, and reserve next/dynamic for components that need explicit control over client-side rendering behavior.

Why did the same next/dynamic call work before I adopted the App Router?

In the Pages Router, every component is implicitly a Client Component with respect to this restriction — there is no Server/Client Component split, so ssr: false has always been legal everywhere. The App Router introduces the split and enforces it at build time, which is why code that worked unchanged in the Pages Router now fails until it is placed behind an explicit ‘use client’ boundary.