Choose and implement the right rendering strategy for performance and SEO. Covers SSR, SSG, ISR, streaming SSR, edge rendering, hydration patterns, and the patterns that deliver fast, SEO-friendly web applications.
Server-side rendering (SSR) strategies are methods for generating HTML on the server before sending it to the client, reducing time-to-content and improving initial load performance, SEO, and interactivity. This matters when users expect fast perceived performance, when content is critical before JavaScript loads, and when crawlers must index rich, dynamic pages. SSR is essential for content-heavy sites, e-commerce, and any application where the first paint is the first impression.
The choice of strategy depends on these factors, not just a preference for “SSR” over “client-side rendering.”
Prefetching and pre-rendering are complementary approaches to reduce time-to-content. Prefetching loads data in the background while the user scrolls or hovers, but does not render the HTML until the data arrives. Pre-rendering generates the complete HTML for a route ahead of time.
Prefetching is best when content is dynamic and user actions are predictable. Use rel="prefetch" on <link> tags to load assets, and use the fetch() API to load data in the background.
<link rel="prefetch" href="/api/posts/42" as="fetch">
<link rel="prefetch" href="/styles/post.css" as="style">
<link rel="prefetch" href="/scripts/post-view.js" as="script">
When the user clicks a link, the browser fetches the next page’s resources in parallel. This reduces the delay between navigation and rendering.
Pre-rendering is best when content is static or rarely updated. Use a build-time step to render HTML for every route.
With npm run build, a typical pre-rendering workflow generates static HTML files per route:
{
"scripts": {
"build": "next build",
"pre-render": "next export"
}
}
The next export command generates a out/ directory with one HTML file per route, including index.html, blog/post-1.html, and so on. Each file includes the complete HTML, inline critical CSS, and a static/ directory for assets.
When deployed to a static server, each file is served directly. No server-side runtime is needed.
Static generation (SSG) combines pre-rendering with dynamic updates. It generates HTML at build time, but allows some data to be fetched at request time.
In a typical SSG setup:
getStaticProps.// pages/blog/[id].js
export async function getStaticProps({ params }) {
const res = await fetch(`https://api.example.com/posts/${params.id}`);
const post = await res.json();
return { props: { post } };
}
export async function getStaticPaths() {
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
const paths = posts.map(post => ({
params: { id: post.id.toString() }
}));
return { paths, fallback: true };
}
The fallback: true option enables incremental static regeneration (ISR). When a request arrives for a route not built at build time, the server:
This strategy balances performance and freshness. It’s ideal for blogs, documentation, and product catalogs.
Dynamic rendering is the most flexible approach. The server generates HTML on every request, using data from a database or API.
Use getServerSideProps to fetch data on every request.
export async function getServerSideProps({ params }) {
const res = await fetch(`https://api.example.com/posts/${params.id}`);
const post = await res.json();
return { props: { post } };
}
This strategy ensures the latest data is always served, but it introduces latency. The server must:
This is best for applications where content changes frequently and users expect up-to-date views. It’s common in dashboards, user profiles, and real-time feeds.
Hybrid SSR blends static and dynamic rendering for optimal performance. Use static rendering for routes with infrequent updates, and dynamic rendering for frequently changing content.
For example:
/ (home) → pre-rendered at build time./blog → static generation with ISR./user/[id] → server-side rendering.This requires coordinating multiple rendering strategies within one application.
In a framework like Next.js, hybrid SSR is achieved by placing pages in different directories:
pages/
├── index.js // pre-rendered at build time
├── blog/
│ ├── index.js // SSG with fallback: true
│ └── [id].js // SSG with fallback: true
└── user/
└── [id].js // SSR with getServerSideProps
The framework automatically selects the rendering strategy based on the presence of getStaticProps, getStaticPaths, and getServerSideProps.
Streaming enables partial HTML delivery, so the browser begins rendering before the entire page is ready.
When the server renders a page, it can send chunks of HTML as they become available.
In a typical setup:
<html>, <head>, and the first <body> chunk.<div> blocks.To enable streaming in a Node.js server:
const { renderToPipeableStream } = require('react-dom/server');
app.get('/blog/:id', (req, res) => {
const { id } = req.params;
const stream = renderToPipeableStream(<BlogPost id={id} />, {
onShellReady() {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
res.write('<!DOCTYPE html><html><head><title>Blog Post</title></head><body>');
stream.pipe(res);
},
onShellError(err) {
console.error(err);
res.statusCode = 500;
res.end('<h1>Something went wrong</h1>');
},
onAllReady() {
res.end('</body></html>');
}
});
});
The onShellReady callback sends the initial shell — the outer structure of the page — while the rest of the content streams in. This reduces perceived load time.
Streaming is ideal for long pages, such as article-heavy sites or dashboards with multiple data sources.
Incremental static regeneration (ISR) allows static pages to be updated after the initial build, without requiring a full rebuild.
It combines the benefits of static generation with the freshness of server-side rendering.
ISR works by:
The fallback: true option in getStaticPaths enables ISR:
export async function getStaticPaths() {
return {
paths: [{ params: { id: '1' } }, { params: { id: '2' } }],
fallback: true
};
}
When a request arrives for a route not yet built, the server:
This means that the first user gets a static page, and subsequent users get a faster, updated version.
To make ISR more efficient, use revalidate in getStaticProps:
export async function getStaticProps({ params }) {
const res = await fetch(`https://api.example.com/posts/${params.id}`);
const post = await res.json();
return {
props: { post },
revalidate: 60 // regenerate every 60 seconds
};
}
The revalidate option tells the server to regenerate the page every 60 seconds. It can serve the cached version while fetching the new one.
Caching is critical to make server-side rendering performant at scale.
Use Cache-Control headers to control how long the browser and CDN cache the response.
HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: public, max-age=3600, stale-while-revalidate=86400
ETag: "abc123"
This header tells the browser and CDN to:
When the server receives a request with If-None-Match: "abc123", it returns 304 Not Modified if the content hasn’t changed.
This reduces latency and bandwidth, especially for high-traffic pages.
For edge delivery, use a CDN to serve static HTML files from locations close to users.
Deploy static HTML files to an edge network (e.g., Cloudflare, Fastly, AWS CloudFront) with a Cache-Control: public, max-age=3600 header.
Use stale-while-revalidate to allow users to see content while the edge server updates the cache.
SSR fails silently when the server cannot generate HTML. Common failure modes include:
To handle these, implement fallbacks:
ErrorBoundary components to catch rendering errors.For example, when a server fails to render a blog post:
export async function getServerSideProps({ params }) {
try {
const res = await fetch(`https://api.example.com/posts/${params.id}`);
if (!res.ok) throw new Error('Failed to fetch post');
const post = await res.json();
return { props: { post } };
} catch (err) {
return {
props: {
post: {
title: 'Error loading post',
body: '<p>Unable to load content. Please try again.</p>'
}
},
revalidate: 60
};
}
}
This ensures that even if the server fails, the user sees a meaningful page.
Additionally, use fallback: 'blocking' in getStaticPaths to block the request until the page is generated:
export async function getStaticPaths() {
return {
paths: [],
fallback: 'blocking'
};
}
With fallback: 'blocking', the server waits for the page to be generated before sending the response. This avoids the “blank page” state during first load.
After the server sends HTML, the client must hydrate the page to make it interactive.
Hydration involves:
The key is to minimize hydration cost. Use hydrateRoot in React to initialize the application.
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(
document.getElementById('root'),
<App />
);
For large pages, consider partial hydration — hydrating only the parts of the page that are visible.
Use IntersectionObserver to detect when a component enters the viewport, and hydrate it on demand.
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const id = entry.target.getAttribute('data-hydrate-id');
hydrateComponent(id);
observer.unobserve(entry.target);
}
});
});
document.querySelectorAll('[data-hydrate-id]').forEach(el => observer.observe(el));
This strategy reduces initial JavaScript bundle size and improves perceived performance.
Common Pitfalls in Server-Side Rendering
Cache-Control headers: Pages are not cached, leading to repeated server requests.fallback: true correctly: Users see outdated content, but the new version takes time to appear.SSR is not a single technique, but a set of interlocking strategies. Choose based on content freshness, user expectations, and infrastructure constraints.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy