Server-Side Rendering Strategies

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.

When to Choose an SSR Strategy

Evaluate the trade-offs before selecting a strategy. Consider:

The choice of strategy depends on these factors, not just a preference for “SSR” over “client-side rendering.”

Prefetching and Pre-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.

Server-Side Rendering with Static Generation

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:

// 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:

  1. Serves the static HTML (from build output).
  2. Fetches data in the background.
  3. Updates the static HTML file on the next request.

This strategy balances performance and freshness. It’s ideal for blogs, documentation, and product catalogs.

Server-Side Rendering with Dynamic Rendering

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:

  1. Receive an HTTP request.
  2. Query a database or external API.
  3. Render the page with the latest data.
  4. Send the HTML.

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: Combining Static and Dynamic Rendering

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:

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.

Server-Side Rendering with Streaming

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:

  1. The server starts rendering the page.
  2. It sends the <html>, <head>, and the first <body> chunk.
  3. As components render, it sends additional <div> blocks.
  4. The browser parses and displays the content incrementally.

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.

Server-Side Rendering with Incremental Static Regeneration

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:

  1. Generating static HTML at build time.
  2. Allowing a page to be regenerated on-demand.
  3. Updating the static file after the first request.

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.

Server-Side Rendering with Caching and Edge Delivery

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.

Server-Side Rendering with Error Handling and Fallbacks

SSR fails silently when the server cannot generate HTML. Common failure modes include:

To handle these, implement fallbacks:

  1. Static fallback HTML: Serve a basic HTML skeleton when rendering fails.
  2. Error boundaries in React: Use ErrorBoundary components to catch rendering errors.
  3. Graceful degradation: Serve static HTML with minimal JavaScript, so the page works even if hydration fails.

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.

Server-Side Rendering with Hydration and Client-Side Interaction

After the server sends HTML, the client must hydrate the page to make it interactive.

Hydration involves:

  1. Parsing the HTML.
  2. Attaching event listeners.
  3. Running JavaScript to activate components.

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

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.