Server Side Rendering Patterns

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.

Preparing the Server for Rendering

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.

Configuration: Environment and Runtime

The server must be configured with environment variables to control behavior across environments. Key values include:

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

Data Fetching During Render

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,
    },
  };
}

Key Failure Modes in Data Fetching

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());
}

Hydration: Bridging Server and Client

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>.

Hydration Gotchas

To mitigate this, use async and defer for the hydration script:

<script src="/main.js" async defer></script>

Caching Strategies

SSR is expensive. Rendering each request from scratch is slow. Caching is essential. Two key patterns are:

  1. In-memory caching: Store rendered HTML in memory (e.g., Map or WeakMap) keyed by pathname and headers.
  2. File-based caching: Write rendered HTML to disk (e.g., 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 Invalidation

Cache is only effective when it expires or is invalidated. The most common method is time-to-live (TTL):

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.

Cache-Aside Pattern

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.

Handling Errors and Fallbacks

SSR fails silently when data is unavailable or a render function crashes. The server must handle these gracefully.

Error Handling in render

Wrap 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',
      },
    };
  }
}

Fallback Rendering

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 };
}

Edge Cases and Production Nuances

<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.