Code Splitting for SSR and React Server Components
Client-only single-page apps treat code splitting as a timing problem: fetch chunk X when the user navigates to route Y. Under server-side rendering (SSR) and React Server Components (RSC), the identical import() call executes twice, in two different runtimes, and the two executions do not agree on what “loaded” means. A server process resolves modules synchronously against the filesystem before the browser has even opened a connection; the client resolves that same import() against a network request that can take 80–300 ms on a cold cache. Porting a client-only lazy boundary into an SSR route without accounting for that gap produces one of three failures: the server throws on a lazy component it cannot resolve synchronously, the initial HTML silently omits the lazy subtree (a flash of missing content costing 150–400 ms of perceived load), or the client re-renders a subtree the server already sent, tripping a hydration mismatch warning on an estimated 10–20% of first-time page loads at scale.
The failure mode compounds once React Server Components enter the picture, because RSC does not just add a second render pass — it adds a second category of module. A Server Component never ships its source to the browser at all; a Client Component ships both server-rendered markup and a JavaScript payload the browser must fetch and execute. Code splitting decisions that ignore this distinction either bloat the client bundle with server-only logic that should have stayed on the server, or, more commonly, attempt to apply a client-only escape hatch like { ssr: false } inside a module that has no client runtime to escape to in the first place. Both mistakes are cheap to make and expensive to diagnose, because the build often succeeds and the failure only shows up as a runtime error or a silently missing subtree in production.
This page extends route-based code splitting and dynamic import strategies into environments where a server process executes route and component modules before the browser ever receives them. Everything that section establishes about chunk boundaries and vendor isolation still applies — SSR and RSC only add a second bundle target that has to agree with the first one. Where implementing route-level code splitting in SPAs treats the router as the sole authority over chunk boundaries, an SSR app effectively has two routers: the one that decided what to render on the server, and the one that hydrates in the browser and must reproduce an identical tree. The dynamic import patterns that work reliably in a pure SPA — conditional imports, retry wrappers, abort-controller cancellation — still apply on the client half of an SSR app, but none of them run during the server pass, which has no concept of a user aborting a fetch mid-render.
Server externals vs. client chunks
The single biggest architectural difference between SSR and a pure SPA is that the server bundle should not contain most of node_modules at all. A Node process can require() a package directly from disk in microseconds; there is no HTTP round trip to save by bundling it, and doing so only inflates the server artifact and slows cold starts on serverless platforms. The client bundle is the opposite: every dependency has to travel over the network, so the vendor chunk isolation strategy from the parent pillar still applies there in full.
// webpack.server.config.js — Webpack 5 (Node server bundle)
const nodeExternals = require('webpack-node-externals');
module.exports = {
target: 'node',
externals: [nodeExternals()], // require() node_modules at runtime instead of bundling them
optimization: {
splitChunks: false, // chunk splitting is meaningless when nothing is fetched over HTTP
},
output: {
filename: 'server.js',
libraryTarget: 'commonjs2',
},
};// webpack.client.config.js — Webpack 5 (browser bundle)
module.exports = {
target: 'web',
optimization: {
moduleIds: 'deterministic',
splitChunks: {
chunks: 'all',
cacheGroups: {
defaultVendors: { test: /[\\/]node_modules[\\/]/, priority: -10 },
},
},
},
output: {
filename: '[name].[contenthash:8].js',
},
};Vite 5+ runs a comparable dual build: an SSR pass invoked with vite build --ssr and a client pass that behaves like any other production build. Vite externalizes most node_modules for the SSR bundle automatically; the escape hatch is ssr.noExternal for the rare package that ships ESM-only code the Node runtime can’t require() directly.
// vite.config.ts — Vite 5+ (dual server/client build)
import { defineConfig } from 'vite';
export default defineConfig({
ssr: {
// Bundle only packages that ship non-Node-compatible ESM; leave everything else external
noExternal: ['some-esm-only-carousel'],
},
build: {
rollupOptions: {
output: {
// manualChunks only affects the client build — the SSR build ignores it entirely
manualChunks(id) {
if (id.includes('node_modules')) return 'vendor';
},
},
},
},
});# Build both targets — the SSR entry never reaches the browser
npx vite build --outDir dist/client
npx vite build --ssr src/entry-server.tsx --outDir dist/serverReact Suspense, lazy, and next/dynamic on the server
React.lazy was designed for the client and, on its own, was never wired into the legacy renderToString renderer — calling it there either throws or produces an empty subtree, because renderToString walks the tree synchronously and cannot await a Promise mid-render. React 18 changed this for apps that adopted the streaming renderers: renderToPipeableStream (Node) and renderToReadableStream (edge runtimes) understand Suspense, including boundaries triggered by lazy(), and will stream a fallback immediately while the real content flushes in once its chunk resolves.
// entry-server.tsx — React 18 streaming SSR with a lazy boundary
import { renderToPipeableStream } from 'react-dom/server';
import { Suspense, lazy } from 'react';
import type { ServerResponse } from 'http';
const ChartPanel = lazy(() => import('./widgets/ChartPanel'));
export function renderApp(res: ServerResponse) {
const { pipe } = renderToPipeableStream(
<Suspense fallback={<div className="chart-skeleton" aria-busy="true" />}>
<ChartPanel />
</Suspense>,
{
onShellReady() {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
pipe(res); // the shell flushes now; ChartPanel streams in once its chunk resolves
},
}
);
}Next.js’s App Router builds on the same primitive but resolves the RSC boundary question for you: everything is a Server Component by default, and next/dynamic renders on the server automatically unless told otherwise. { ssr: false } is the escape hatch for browser-only widgets, and it is only legal inside a Client Component — a module marked with the 'use client' directive.
// app/dashboard/page.tsx — Next.js App Router (Server Component)
import ChartPanelClient from './chart-panel-client';
export default function DashboardPage() {
// No 'use client' here — this file never ships to the browser as JavaScript
return (
<section>
<h1>Dashboard</h1>
<ChartPanelClient />
</section>
);
}// app/dashboard/chart-panel-client.tsx — Next.js App Router (Client Component)
'use client';
import dynamic from 'next/dynamic';
const ChartPanel = dynamic(() => import('./ChartPanel'), {
loading: () => <div className="chart-skeleton" aria-busy="true" />,
ssr: false, // valid here — this whole module already runs only in the browser
});
export default function ChartPanelClient() {
return <ChartPanel />;
}Apps still running the legacy, non-streaming renderToString cannot rely on React 18’s built-in Suspense-on-the-server support, but they can still split their bundles safely with loadable-components, which resolves lazy imports synchronously during the server pass and hands back the exact <script>/<link> tags the initial HTML needs:
// entry-server.tsx — @loadable/component with renderToString
import { renderToString } from 'react-dom/server';
import { ChunkExtractor } from '@loadable/server';
import App from './App';
export function render(url: string) {
const extractor = new ChunkExtractor({
statsFile: require.resolve('../dist/loadable-stats.json'),
});
const html = renderToString(extractor.collectChunks(<App url={url} />));
const scriptTags = extractor.getScriptTags(); // <script> tags for every chunk this render touched
const linkTags = extractor.getLinkTags(); // <link rel="preload"> for the same chunks
return { html, scriptTags, linkTags };
}Vue 3 and Nuxt 3: SSR-aware splitting without extra tooling
React’s ecosystem needed loadable-components and, later, the streaming renderers specifically because React.lazy was not SSR-aware by default. Vue 3 avoided that gap from the start: defineAsyncComponent has built-in server rendering support, so a component wrapped with it renders synchronously during renderToString (or Nuxt 3’s streaming equivalent) without a separate chunk-extraction step. This is a meaningful architectural difference worth calling out if your team is choosing between frameworks for a new SSR project, or maintaining both.
// pages/dashboard.vue script block — Nuxt 3 (Vue 3 SSR, no extra library needed)
<script setup lang="ts">
import { defineAsyncComponent } from 'vue';
const ChartPanel = defineAsyncComponent({
loader: () => import('~/widgets/ChartPanel.vue'),
loadingComponent: () => import('~/widgets/ChartSkeleton.vue'),
delay: 100,
// Nuxt's server renderer resolves this during SSR and emits the matching
// preload link automatically — no ChunkExtractor equivalent required.
});
</script>The trade-off is control: Nuxt’s automatic manifest handling is convenient until a route needs a component excluded from the server render entirely (an interactive map widget with no meaningful server-rendered fallback, for instance). In that case Nuxt exposes <ClientOnly>, a component-level equivalent of next/dynamic’s { ssr: false }, but — like the Next.js case — it can only wrap a component, not silently disable rendering for an entire page tree.
The diagram below traces one request through all four stages — server render, chunk manifest, HTML response, and client hydration — and contrasts the server externals decision with the client vendor chunk decision that governs the same dependency graph on the other side of the wire.
Quantified impact
- Marking third-party dependencies external in the server build typically cuts server bundle size by 70–85%, which shortens cold-start latency on serverless platforms by 300–600 ms per invocation.
- Streaming with
renderToPipeableStreamand per-widget Suspense boundaries trims Time to First Byte by 200–500 ms versus blocking on a single slow lazy chunk under legacyrenderToString. - Flushing the chunk manifest (via loadable’s
getScriptTags()or Next’s built-in manifest) before hydration eliminates the majority of the 10–20% hydration mismatch rate quoted above in production RSC and SSR apps. - Keeping
next/dynamic’s default server rendering intact — rather than reflexively addingssr: falseeverywhere — keeps the initial HTML payload complete and avoids an extra client-only round trip that a naive client-only split would introduce. - Drawing Server/Client Component boundaries around genuinely interactive leaves (a chart, a rich-text editor) instead of around entire page sections routinely removes 25–40% of the JavaScript that would otherwise ship to the client, because everything above that leaf stays a Server Component and never compiles to a client bundle at all.
Common pitfalls
- Blank subtree in the server HTML. Diagnostic signal: view-source shows an empty container where a lazy component should render, with no console error. Root cause:
React.lazyused insiderenderToStringrather than a streaming renderer. Fix: switch torenderToPipeableStream/renderToReadableStream, or adopt loadable-components’ChunkExtractorif you’re pinned to the legacy renderer. - Build error on
ssr: falseinside a Server Component. Diagnostic signal: the build fails with an error namingnext/dynamicandssr: falsetogether. Root cause: no client runtime exists inside a component that never ships JavaScript to the browser. Fix: move the dynamic import behind a'use client'boundary — see using dynamic import() in React Server Components for the exact refactor. - Duplicated vendor code across server and client bundles. Diagnostic signal: the server bundle balloons past 5–10 MB despite serving only HTML. Root cause: a missing
externals/ssr.externalconfiguration re-bundles everynode_modulespackage the client already ships separately. Fix: applytarget: 'node'+externals(Webpack) or rely on Vite’s default SSR externalization, opting only ESM-only packages intonoExternal. - A returning visitor hydrates against stale chunk references. Diagnostic signal:
ChunkLoadErroror a syntax error fires immediately after a deploy, only for users who had the site open in a background tab. Root cause: a service worker or long-lived HTTP cache served a pre-deploy HTML shell that still points at chunk filenames the new deploy deleted. This is common enough in SSR apps — which lean on aggressive caching to offset render cost — that it has its own dedicated walkthrough in invalidating service worker cache after deploy.
Verification workflow
Treat SSR and RSC chunk verification as a two-sided check: confirm the server produced the markup you expect, and separately confirm the client can retrieve everything that markup implies it will need. Skipping either side lets a regression on one runtime hide behind a passing check on the other.
- DevTools view-source diff. Compare “View Page Source” (the raw server HTML) against the rendered DOM after hydration. Any subtree present in one but not the other indicates a lazy boundary that behaves differently between server and client.
- Network waterfall check. Filter the Network panel by JS and confirm chunk fetches referenced by the manifest complete before
hydrateRootattaches — a fetch that starts after interaction handlers are already expected to work is a sign the manifest wasn’t flushed early enough. - Bundle stats comparison. Generate separate stats files for the server and client builds and diff them for overlapping
node_modulesentries; overlap larger than a handful of small utility packages signals a missing externals rule. - CI size and snapshot gates. Assert the server bundle stays under a fixed serverless cold-start budget (for example 2 MB) and snapshot-test the SSR HTML output between commits to catch a lazy subtree that silently stopped rendering.
- Cross-runtime console audit. Run the same route through both
next build && next start(or your framework’s production server) and a browser with JavaScript disabled. Content missing in the no-JS view but present with JavaScript enabled is expected for{ ssr: false }widgets; content missing in both is a sign the Server Component boundary swallowed markup it should have rendered.
Every one of these checks assumes the chunk hashes referenced in a given HTML response actually exist on the server that serves them next. A deterministic chunk hashing strategy keeps that assumption valid across ordinary deploys; a caching layer that outlives the deploy — most often a service worker — is the one component that can violate it anyway, which is why the second companion guide below treats it as its own failure category rather than a footnote here.
None of this replaces the baseline chunk-boundary discipline the parent pillar establishes — it layers a second set of constraints on top of it. A team that has already stabilized dynamic import patterns and vendor isolation on the client is in a much better position to add SSR or migrate to the App Router than one that is solving both problems simultaneously, because most of the failure modes above are really “the client-side rule was correct, but it was applied to the wrong runtime.”
FAQ
Does React.lazy work with server-side rendering?
Not with the legacy renderToString API — lazy() throws or silently renders nothing for that subtree because renderToString cannot wait on a dynamic import() Promise. React 18’s streaming APIs, renderToPipeableStream and renderToReadableStream, do support React.lazy and Suspense on the server: the fallback streams first and the real chunk content flushes in when its import() resolves. If you’re stuck on the legacy API or a pre-18 React version, use loadable-components instead, which resolves lazy imports synchronously during the server pass.
What is the difference between next/dynamic and React.lazy for SSR?
React.lazy has no built-in server rendering support outside React 18’s streaming renderers. next/dynamic wraps the same dynamic import() mechanism but adds automatic server rendering by default — the component renders on the server and hydrates on the client without extra plumbing. Passing { ssr: false } opts a component out of server rendering entirely, which is useful for browser-only widgets but is only valid inside a Client Component.
Why do I get a hydration mismatch warning after adding code splitting to my SSR app?
A hydration mismatch means the DOM tree the browser produced on its first pass differs from the markup the server sent. This usually happens when a lazy or dynamically imported component renders on the server but the client has not loaded that chunk yet at hydration time, or the reverse — the server skipped it and the client renders it immediately. Align both passes by using identical conditional loading logic in both environments and flushing the chunk manifest before hydrateRoot runs.
Can Server Components use dynamic import() directly?
Yes — a Server Component can call import() to split its own server-side dependency graph, and this is unrelated to next/dynamic’s ssr option, which only controls client-side rendering behavior. What a Server Component cannot do is pass { ssr: false } to next/dynamic, because there is no client-side runtime concept inside a component that never ships JavaScript to the browser in the first place.
Related
- Route-Based Code Splitting & Dynamic Import Strategies — the section overview covering chunk boundaries, vendor isolation, and prefetching this page extends
- Using dynamic import() in React Server Components — fixing the exact ssr:false build error and Server/Client boundary placement
- Invalidating Service Worker Cache After Deploy — keeping precached SSR shells from serving stale chunk references
- Streaming SSR and Selective Hydration with Code Splitting — avoiding a hydration waterfall across streamed Suspense boundaries
- Implementing Route-Level Code Splitting in SPAs — the client-only router patterns this page’s server-aware boundaries build on
- Vendor Chunk Isolation and Third-Party Management — the client-side cache-group strategy that server externals must not duplicate