Production engineering guide for server side rendering patterns covering patterns, implementation strategies, and operational best practices.
Server-side rendering (SSR) patterns are the essential strategies for delivering fully-rendered HTML from the server to the client, improving time-to-first-content, SEO, and perceived performance. This approach matters when initial page load is critical—on slow networks, for content-heavy sites, or for applications where users expect instant interaction. SSR is not a single technique but a set of interlocking patterns that address hydration, data loading, caching, and error handling, all of which must be orchestrated at scale.
A server must be able to accept requests, execute the application logic, and return complete HTML. The most common pattern uses an HTTP server that listens on a port and handles incoming requests. The server runs a built-in or external process that boots the application, resolves routes, and executes render functions.
For a Node.js application, the server is typically a server.js file that starts an http.Server or uses Express, Fastify, or Hono. The entry point must export a render function that takes a request object and returns a Response object with body, status, and headers.
// server.js
import { createServer } from 'http';
import { render } from './render';
const server = createServer(async (req, res) => {
const { url, method } = req;
const response = await render({ url, method, headers: req.headers });
res.statusCode = response.status || 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.write(response.body);
res.end();
});
server.listen(3000);
The render function must resolve the route, load data, and generate HTML. It may use fetch for API calls, readFileSync for static assets, and renderToString from a rendering library like React, Preact, or Svelte.
The server must be configured with environment variables to control behavior across environments. Key values include:
NODE_ENV=production to enable optimizations like minification and caching.SSR_CACHE_TTL=300 to set cache expiration in seconds.SSR_ENABLE_HYDRATION=true to enable client-side hydration.SSR_FALLBACK_MODE=static to control fallback behavior when rendering fails.The server process is typically managed by systemd, pm2, or supervisor. A systemd service file for a Node.js SSR server looks like:
[Unit]
Description=SSR Server
After=network.target
[Service]
Type=exec
User=ssr
WorkingDirectory=/app
ExecStart=/usr/bin/node server.js
Restart=always
Environment=NODE_ENV=production
Environment=SSR_CACHE_TTL=300
Environment=SSR_ENABLE_HYDRATION=true
[Install]
WantedBy=multi-user.target
The core of SSR is fetching data before sending HTML. The most common pattern is to use async functions within the render pipeline. The render function calls getServerSideProps (a standard name across frameworks) to gather data before rendering.
// render.js
import { renderToString } from 'react-dom/server';
import { getServerSideProps } from './pages/[id]';
export async function render({ url, method, headers }) {
const pathname = new URL(url, 'http://localhost').pathname;
const props = await getServerSideProps({ pathname, headers });
const html = renderToString(<Page {...props} />);
return {
body: `<!DOCTYPE html><html><head><title>${props.title}</title></head><body>${html}</body></html>`,
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'public, max-age=600',
},
};
}
getServerSideProps may use fetch to call external APIs, fs.readFile to load JSON files, or pg.query for PostgreSQL.
// pages/[id].js
export async function getServerSideProps({ pathname, headers }) {
const id = pathname.split('/').pop();
const [post, comments] = await Promise.all([
fetch(`https://api.example.com/posts/${id}`, {
headers: { 'Authorization': headers.authorization },
}).then(res => res.json()),
fetch(`https://api.example.com/comments?postId=${id}`).then(res => res.json()),
]);
return {
props: {
title: post.title,
content: post.content,
comments,
},
};
}
fetch timeout, the client may show a loading state while the server continues rendering.getServerSideProps runs multiple times for the same route, but the data changes between calls, the rendered HTML may contain stale data.headers in JSON.stringify), the cache is ineffective.To prevent cache misses, use a consistent cache key:
const cacheKey = JSON.stringify({ pathname, headers });
A common mistake is using JSON.stringify without sorting keys, leading to different keys for the same input. To fix this:
function sortedJSON(str) {
return JSON.stringify(JSON.parse(str), Object.keys(JSON.parse(str)).sort());
}
After the server sends HTML, the client must "hydrate" the static DOM into a live React, Vue, or Svelte application. Hydration is the process of attaching event listeners, state, and lifecycle logic to the static HTML.
The client starts by loading a main.js bundle and calling hydrate on the root component.
<script src="/main.js" async></script>
<script>
// Client-side hydration
import { hydrate } from 'react-dom';
import App from './App';
hydrate(<App />, document.getElementById('root'));
</script>
Hydration expects the client-side state to match the server-rendered state. If it does not, React issues warnings in the console:
Warning: Expected server HTML to contain a matching <div> in <main>.
state = { count: 0 }, but the client starts with count: 1, the hydration fails, and React re-renders the entire tree.DOMContentLoaded.To mitigate this, use async and defer for the hydration script:
<script src="/main.js" async defer></script>
SSR is expensive. Rendering each request from scratch is slow. Caching is essential. Two key patterns are:
Map or WeakMap) keyed by pathname and headers.fs.writeFile) and serve from a static directory.const cache = new Map();
export async function render({ url, method, headers }) {
const cacheKey = JSON.stringify({ url, method, headers });
const cached = cache.get(cacheKey);
if (cached) {
return cached;
}
const props = await getServerSideProps({ url, method, headers });
const html = renderToString(<Page {...props} />);
const response = {
body: `<!DOCTYPE html><html>${html}</html>`,
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'public, max-age=600',
},
};
cache.set(cacheKey, response);
return response;
}
Cache is only effective when it expires or is invalidated. The most common method is time-to-live (TTL):
Cache-Control: public, max-age=300 in the response.Cache-Control: s-maxage=3600 to allow CDN caching.When data changes, invalidate the cache:
// After updating a post
await fetch('/api/invalidation', {
method: 'POST',
body: JSON.stringify({ type: 'post', id: 123 }),
headers: { 'Content-Type': 'application/json' },
});
The server listens for invalidation events and clears or updates the cache.
A robust pattern is the "cache-aside" strategy: fetch from cache first, then compute and write to cache.
export async function render({ url, method, headers }) {
const cacheKey = JSON.stringify({ url, method, headers });
const cached = cache.get(cacheKey);
if (cached) {
return cached;
}
const props = await getServerSideProps({ url, method, headers });
const html = renderToString(<Page {...props} />);
const response = {
body: `<!DOCTYPE html><html>${html}</html>`,
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'public, max-age=300',
},
};
cache.set(cacheKey, response);
// Write to disk in background
const filePath = `/cache/${cacheKey}.html`;
fs.writeFile(filePath, response.body, { flag: 'w' });
return response;
}
This pattern ensures that the cache is warm, and rendering is fast even when the cache is cold.
SSR fails silently when data is unavailable or a render function crashes. The server must handle these gracefully.
renderWrap render in a try-catch block and return error responses:
export async function render({ url, method, headers }) {
try {
const props = await getServerSideProps({ url, method, headers });
const html = renderToString(<Page {...props} />);
return {
body: `<!DOCTYPE html><html>${html}</html>`,
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'public, max-age=300',
},
};
} catch (error) {
console.error('SSR render failed:', error);
return {
body: `
<!DOCTYPE html>
<html>
<head><title>500: Server Error</title></head>
<body>
<h1>500: Server Error</h1>
<p>An error occurred while rendering this page.</p>
</body>
</html>
`,
status: 500,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'public, max-age=60',
},
};
}
}
When a requested page is not found, the server returns a fallback page. This is common for dynamic routes like /posts/[id].
export async function getServerSideProps({ pathname, headers }) {
const id = pathname.split('/').pop();
const response = await fetch(`https://api.example.com/posts/${id}`);
if (!response.ok) {
return { notFound: true };
}
const post = await response.json();
return { props: { post } };
}
When notFound: true is returned, the render function checks for a fallback HTML file:
if (props.notFound) {
const fallback = await fs.readFile('./static/404.html', 'utf-8');
return { body: fallback, status: 404 };
}
console.error is captured.hydrated class: After hydration, the client adds a hydrated class to the root. The server should include hydrated in the initial HTML to reduce flash.getServerSideProps runs every request, but data is updated frequently, the HTML may contain stale data. Use stale-while-revalidate in the client to refetch after hydration.preload and prefetch in the server-rendered <head> to improve perceived performance.<link rel="preload" as="image" href="/hero.jpg" />
<link rel="prefetch" as="script" href="/analytics.js" />
These patterns form a robust, production-ready SSR pipeline. They are not tied to any framework or platform but are universally applicable across any environment where HTML is rendered on the server and hydrated on the client.
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