SSR and lazy loading of components
Reminder: what SSR and lazy loading are
| Mechanism | What it does |
|---|---|
| SSR (Server-Side Rendering) | Generates HTML on the server, so the page renders immediately (without waiting for JS). |
| Component Lazy Loading | Loads a component (or its code) only when needed - so the whole bundle isn't pulled in at once. |
How they interact
When you use React.lazy() or dynamic import() in an SSR app:
- The server starts rendering the React components.
- If it runs into a lazy component that isn't loaded yet (its code is split into a separate JS chunk), the server cannot render it right now.
- React either:
- stops the render (in classic SSR);
- or shows the fallback from
<Suspense>and keeps streaming (in Streaming SSR); - or loads the module synchronously, if it's already in memory (in dev mode).
1. In classic SSR (before React 18)
React.lazy() effectively blocks rendering if its module isn't loaded yet.
For example:
const LazyComp = React.lazy(() => import('./BigChart'));
function Page() {
return (
<div>
<Header />
<LazyComp /> {/* SSR gets "stuck" here */}
</div>
);
}On the server, import('./BigChart') is a Promise,
but classic SSR (renderToString) cannot wait for promises.
The result:
- The server throws an error: "A component suspended while rendering, but no fallback UI was specified."
- Or it simply cannot finish the SSR.
So before React 18, lazy components didn't work on the server - they had to be loaded synchronously or SSR had to be turned off for them (ssr: false in Next.js).
2. In React 18 and Streaming SSR
React 18 completely changed the rules of the game. Now SSR knows how to work with Suspense and lazy components asynchronously.
What happens:
- The server starts rendering HTML.
- When React runs into a lazy component, it "suspends" the render of that part.
- It streams out the fallback HTML (for example,
<Skeleton />). - When the module loads, React continues streaming and inserts the ready content later.
Example:
import { Suspense, lazy } from 'react';
const LazyComp = lazy(() => import('./BigChart'));
export default function Page() {
return (
<div>
<Header />
<Suspense fallback={<p>Loading chart...</p>}>
<LazyComp />
</Suspense>
</div>
);
}In Streaming SSR:
<Header />and<p>Loading chart...</p>render and are sent to the client immediately;- when
BigChartloads, the server sends the rest of the HTML.
This makes SSR asynchronous, smooth, and "lazy" by nature.
3. In Next.js
In Next.js (from version 13, App Router), this is built in out of the box:
import dynamic from 'next/dynamic';
const LazyChart = dynamic(() => import('./Chart'), {
ssr: true, // or false, if you don't want to render on the server
loading: () => <p>Loading...</p>,
});
export default function Page() {
return (
<>
<Header />
<LazyChart />
</>
);
}ssr: true→ the component will be rendered on the server (through Suspense).ssr: false→ the component is fully excluded from SSR and renders only on the client.
This gives flexibility: you can choose "lazy, but with SSR" or "lazy, but browser-only".
Advantages of this interaction
| Advantage | Description |
|---|---|
| Faster first render | SSR sends HTML immediately, even if some components load lazily |
| Smaller JS bundle | Lazy loading reduces the initial bundle size |
| Smooth content loading | Through Suspense, SSR sends a fallback, then replaces it with the ready component |
| Support for async data | Lazy components can load data from an API without blocking the whole SSR |
Potential difficulties
| Problem | Description |
|---|---|
React.lazy cannot be used without Suspense | Otherwise SSR "hangs" |
| The fallback can stay longer than expected | If the import is heavy or the network is slow |
The old renderToString doesn't support Suspense | You need renderToPipeableStream() |
| Chunk caching | The CDN needs to be configured so lazy components' JS chunks load correctly |
A full SSR + lazy loading + Suspense example
import { Suspense, lazy } from 'react';
import { renderToPipeableStream } from 'react-dom/server';
const LazySection = lazy(() => import('./Section'));
function App() {
return (
<div>
<Header />
<Suspense fallback={<LoadingSection />}>
<LazySection />
</Suspense>
</div>
);
}
// Server
app.get('/', (req, res) => {
const stream = renderToPipeableStream(<App />, {
onShellReady() {
res.setHeader('Content-Type', 'text/html');
stream.pipe(res);
},
});
});The client gets the header and LoadingSection immediately,
and Section gets added to the DOM later, once it's ready.
Conclusion
SSR and lazy loading now combine really well - thanks to React 18 and Streaming SSR:
| Criterion | Old SSR | Streaming SSR |
|---|---|---|
| Do lazy components work? | No | Yes |
| Is Suspense supported? | No | Yes |
| Can content be sent in parts? | No | Yes |
| UX without a "blank screen"? | Not always | Always |
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.