← Back to DevBytes

Server-Side Rendering with Webpack: SSR, SSG, ISR

Server-Side Rendering with Webpack: SSR, SSG, ISR

Modern web applications demand fast initial page loads, SEO-friendly markup, and dynamic content. While client-side rendering (CSR) dominated the SPA era, rendering strategies like Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR) have returned to the spotlight. In this tutorial, you'll learn how to implement all three rendering strategies from scratch using Webpack, without relying on a meta-framework like Next.js.

What Are SSR, SSG, and ISR?

Before diving into code, it's important to understand the differences between these rendering strategies and when to use each one.

Why Webpack?

Frameworks abstract away the build complexity, but understanding how SSR, SSG, and ISR work at the Webpack level gives you full control. You'll learn how to bundle code for both the browser and Node.js, share modules between environments, and orchestrate rendering pipelines. This knowledge is essential for building custom frameworks, optimizing performance, or debugging production issues.

Project Setup

Let's start by creating a new project and installing the necessary dependencies.

mkdir webpack-rendering
cd webpack-rendering
npm init -y
npm install webpack webpack-cli babel-loader @babel/core @babel/preset-env @babel/preset-react react react-dom express --save-dev

Create the following directory structure:

webpack-rendering/
├── src/
│   ├── App.js
│   ├── pages/
│   │   ├── Home.js
│   │   ├── Post.js
│   │   └── Dashboard.js
│   ├── client.js
│   └── server.js
├── build/
├── public/
├── webpack.client.js
├── webpack.server.js
└── server.js

Building the React Application

First, let's create a simple React application with three pages that demonstrate each rendering strategy.

// src/App.js
import React from 'react';

export default function App({ page, data }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <title>{page.title}</title>
      </head>
      <body>
        <div id="root">{page.render(data)}</div>
        <script src="/client.js" defer></script>
      </body>
    </html>
  );
}
// src/pages/Home.js
import React from 'react';

export default function Home({ posts }) {
  return (
    <div>
      <h1>Blog Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>
            <a href={`/posts/${post.slug}`}>{post.title}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}
// src/pages/Post.js
import React from 'react';

export default function Post({ post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <p>By {post.author} · {post.date}</p>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}
// src/pages/Dashboard.js
import React from 'react';

export default function Dashboard({ user, stats }) {
  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      <ul>
        <li>Posts: {stats.posts}</li>
        <li>Followers: {stats.followers}</li>
        <li>Engagement: {stats.engagement}%</li>
      </ul>
    </div>
  );
}

Configuring Webpack for the Client

The client bundle is responsible for hydrating the server-rendered HTML. It needs to run in the browser and attach event listeners to the existing DOM.

// webpack.client.js
const path = require('path');

module.exports = {
  mode: 'production',
  entry: './src/client.js',
  output: {
    path: path.resolve(__dirname, 'public'),
    filename: 'client.js',
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env', '@babel/preset-react'],
          },
        },
      },
    ],
  },
};
// src/client.js
import React from 'react';
import { hydrateRoot } from 'react-dom/client';

const initialState = window.__INITIAL_DATA__;
const page = initialState.page;
const data = initialState.data;

hydrateRoot(
  document.getElementById('root'),
  page.render(data)
);

Configuring Webpack for the Server

The server bundle runs in Node.js. It must target the CommonJS module format and avoid bundling Node.js built-ins. We use target: 'node' and mark react-dom/server as external.

// webpack.server.js
const path = require('path');
const nodeExternals = require('webpack-node-externals');

module.exports = {
  mode: 'production',
  entry: './src/server.js',
  target: 'node',
  externals: [nodeExternals()],
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: 'server.js',
    libraryTarget: 'commonjs2',
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env', '@babel/preset-react'],
          },
        },
      },
    ],
  },
};

Install webpack-node-externals to prevent bundling node_modules into the server bundle:

npm install webpack-node-externals --save-dev

Implementing the Server Renderer

The server module exports a render function that accepts a route and returns HTML. This is the heart of all three rendering strategies.

// src/server.js
import React from 'react';
import { renderToString } from 'react-dom/server';
import App from './App';
import Home from './pages/Home';
import Post from './pages/Post';
import Dashboard from './pages/Dashboard';

const pages = {
  home: { title: 'Home', render: (data) => <Home posts={data.posts} /> },
  post: { title: 'Post', render: (data) => <Post post={data.post} /> },
  dashboard: { title: 'Dashboard', render: (data) => <Dashboard user={data.user} stats={data.stats} /> },
};

export async function render(pageKey, data) {
  const page = pages[pageKey];
  if (!page) throw new Error(`Unknown page: ${pageKey}`);

  const appHtml = renderToString(
    <App page={page} data={data} />
  );

  const stateScript = `<script>window.__INITIAL_DATA__ = ${JSON.stringify({ page: pageKey, data }).replace(/<\/script>/g, '<\\/script>')};</script>`;

  return appHtml.replace(
    '<script src="/client.js" defer></script>',
    `${stateScript}<script src="/client.js" defer></script>`
  );
}

Implementing SSR (Server-Side Rendering)

SSR generates HTML on every request. This is ideal for personalized content like the dashboard page, where data depends on the logged-in user.

// server.js (Express server)
const express = require('express');
const path = require('path');
const { render } = require('./build/server');

const app = express();

app.use(express.static(path.join(__dirname, 'public')));

// Mock data fetchers
async function fetchUser(req) {
  // In production, decode a session token from req.headers.cookie
  return { name: 'Alice', id: 'u_123' };
}

async function fetchStats(userId) {
  return { posts: 42, followers: 1280, engagement: 7.3 };
}

// SSR route - rendered fresh on every request
app.get('/dashboard', async (req, res) => {
  try {
    const user = await fetchUser(req);
    const stats = await fetchStats(user.id);
    const html = await render('dashboard', { user, stats });
    res.status(200).send(`<!DOCTYPE html>${html}`);
  } catch (err) {
    console.error('SSR error:', err);
    res.status(500).send('Internal Server Error');
  }
});

app.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});

How SSR Works Here

When a user visits /dashboard, Express calls the render function from the server bundle. React's renderToString produces HTML synchronously, which is sent to the browser. The client bundle then hydrates the page, attaching event listeners without re-rendering the DOM.

Implementing SSG (Static Site Generation)

SSG pre-renders pages at build time. We'll create a build script that fetches data, renders each page to HTML, and writes the result to disk as static files.

// scripts/generate-static.js
const fs = require('fs');
const path = require('path');
const { render } = require('../build/server');

// Mock data source - replace with a CMS API or database
async function fetchAllPosts() {
  return [
    { id: 1, slug: 'hello-world', title: 'Hello World', author: 'Alice', date: '2024-01-15', content: '<p>Welcome to my blog.</p>' },
    { id: 2, slug: 'webpack-ssr', title: 'Webpack SSR Guide', author: 'Bob', date: '2024-02-20', content: '<p>A deep dive into SSR.</p>' },
  ];
}

async function fetchPost(slug) {
  const posts = await fetchAllPosts();
  return posts.find(p => p.slug === slug);
}

async function generate() {
  const outputDir = path.resolve(__dirname, '../public/static');
  fs.mkdirSync(outputDir, { recursive: true });

  // Generate the home page
  const posts = await fetchAllPosts();
  const homeHtml = await render('home', { posts });
  fs.writeFileSync(path.join(outputDir, 'index.html'), `<!DOCTYPE html>${homeHtml}`);

  // Generate each post page
  for (const post of posts) {
    const fullPost = await fetchPost(post.slug);
    const postHtml = await render('post', { post: fullPost });
    const postDir = path.join(outputDir, 'posts', post.slug);
    fs.mkdirSync(postDir, { recursive: true });
    fs.writeFileSync(path.join(postDir, 'index.html'), `<!DOCTYPE html>${postHtml}`);
    console.log(`Generated /posts/${post.slug}`);
  }

  console.log('Static generation complete.');
}

generate().catch(err => {
  console.error('Generation failed:', err);
  process.exit(1);
});

Add this script to your package.json:

{
  "scripts": {
    "build:client": "webpack --config webpack.client.js",
    "build:server": "webpack --config webpack.server.js",
    "build": "npm run build:client && npm run build:server",
    "generate": "node scripts/generate-static.js",
    "start": "node server.js"
  }
}

Now update the Express server to serve the static files for SSG routes:

// Add to server.js
app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, 'public/static/index.html'));
});

app.get('/posts/:slug', (req, res) => {
  const filePath = path.join(__dirname, 'public/static/posts', req.params.slug, 'index.html');
  if (fs.existsSync(filePath)) {
    res.sendFile(filePath);
  } else {
    res.status(404).send('Not Found');
  }
});

Implementing ISR (Incremental Static Regeneration)

ISR combines the speed of SSG with the freshness of SSR. Pages are served from a static cache, but a background process regenerates them at a configurable interval. We'll implement this using a time-stamped cache and a stale-while-revalidate pattern.

// server.js - ISR implementation
const cache = new Map(); // In production, use Redis or a file-based cache

const ISR_REVALIDATE_SECONDS = 60; // Regenerate every 60 seconds

async function fetchPostISR(slug) {
  // Simulate fetching fresh data from a CMS
  const posts = await fetchAllPosts();
  const post = posts.find(p => p.slug === slug);
  if (post) {
    return { ...post, generatedAt: new Date().toISOString() };
  }
  return null;
}

async function getOrRegenerate(slug) {
  const cached = cache.get(slug);

  if (cached) {
    const age = (Date.now() - cached.timestamp) / 1000;

    if (age < ISR_REVALIDATE_SECONDS) {
      // Serve fresh cache
      return cached.html;
    }

    if (age < ISR_REVALIDATE_SECONDS * 10) {
      // Serve stale cache while regenerating in background
      regenerateInBackground(slug);
      return cached.html;
    }
  }

  // No cache or too stale - generate synchronously
  const post = await fetchPostISR(slug);
  if (!post) return null;

  const html = await render('post', { post });
  cache.set(slug, { html, timestamp: Date.now() });
  return html;
}

async function regenerateInBackground(slug) {
  try {
    const post = await fetchPostISR(slug);
    if (!post) return;
    const html = await render('post', { post });
    cache.set(slug, { html, timestamp: Date.now() });
    console.log(`Regenerated ISR page: ${slug}`);
  } catch (err) {
    console.error(`ISR regeneration failed for ${slug}:`, err);
  }
}

// ISR route - serves cached HTML, regenerates in background
app.get('/posts/:slug/isr', async (req, res) => {
  const html = await getOrRegenerate(req.params.slug);
  if (html) {
    res.status(200).send(`<!DOCTYPE html>${html}`);
  } else {
    res.status(404).send('Not Found');
  }
});

Understanding the ISR Flow

The ISR implementation follows a stale-while-revalidate strategy:

Putting It All Together

Here is the complete Express server combining all three strategies:

// server.js (complete)
const express = require('express');
const fs = require('fs');
const path = require('path');
const { render } = require('./build/server');

const app = express();
app.use(express.static(path.join(__dirname, 'public')));

const cache = new Map();
const ISR_REVALIDATE_SECONDS = 60;

// --- Data fetchers ---
async function fetchAllPosts() {
  return [
    { id: 1, slug: 'hello-world', title: 'Hello World', author: 'Alice', date: '2024-01-15', content: '<p>Welcome to my blog.</p>' },
    { id: 2, slug: 'webpack-ssr', title: 'Webpack SSR Guide', author: 'Bob', date: '2024-02-20', content: '<p>A deep dive into SSR.</p>' },
  ];
}

async function fetchUser(req) {
  return { name: 'Alice', id: 'u_123' };
}

async function fetchStats(userId) {
  return { posts: 42, followers: 1280, engagement: 7.3 };
}

// --- SSG routes ---
app.get('/', (req, res) => {
  const filePath = path.join(__dirname, 'public/static/index.html');
  if (fs.existsSync(filePath)) {
    res.sendFile(filePath);
  } else {
    res.status(404).send('Run "npm run generate" first');
  }
});

// --- ISR routes ---
async function regenerateInBackground(slug) {
  try {
    const posts = await fetchAllPosts();
    const post = posts.find(p => p.slug === slug);
    if (!post) return;
    const html = await render('post', { post: { ...post, generatedAt: new Date().toISOString() } });
    cache.set(slug, { html, timestamp: Date.now() });
  } catch (err) {
    console.error('ISR regeneration failed:', err);
  }
}

app.get('/posts/:slug', async (req, res) => {
  const slug = req.params.slug;
  const cached = cache.get(slug);

  if (cached) {
    const age = (Date.now() - cached.timestamp) / 1000;
    if (age < ISR_REVALIDATE_SECONDS) {
      return res.status(200).send(`<!DOCTYPE html>${cached.html}`);
    }
    if (age < ISR_REVALIDATE_SECONDS * 10) {
      regenerateInBackground(slug);
      return res.status(200).send(`<!DOCTYPE html>${cached.html}`);
    }
  }

  const posts = await fetchAllPosts();
  const post = posts.find(p => p.slug === slug);
  if (!post) return res.status(404).send('Not Found');

  const html = await render('post', { post: { ...post, generatedAt: new Date().toISOString() } });
  cache.set(slug, { html, timestamp: Date.now() });
  res.status(200).send(`<!DOCTYPE html>${html}`);
});

// --- SSR routes ---
app.get('/dashboard', async (req, res) => {
  try {
    const user = await fetchUser(req);
    const stats = await fetchStats(user.id);
    const html = await render('dashboard', { user, stats });
    res.status(200).send(`<!DOCTYPE html>${html}`);
  } catch (err) {
    console.error('SSR error:', err);
    res.status(500).send('Internal Server Error');
  }
});

app.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});

Best Practices

1. Separate Bundles for Client and Server

Always maintain distinct Webpack configurations for the browser and Node.js. The client bundle should be minified and tree-shaken, while the server bundle should externalize node_modules to keep build times fast and avoid bundling Node.js built-ins.

2. Avoid Window and Document on the Server

Code that references window, document, or other browser-only APIs will crash during SSR. Guard these references with typeof window !== 'undefined' checks, or use useEffect for browser-only logic since effects don't run during renderToString.

3. Serialize State Safely

When embedding initial state in the HTML, always escape </script> tags to prevent XSS attacks. The replace call in the render function handles this, but consider using a dedicated serialization library like serialize-javascript for production.

4. Use a Real Cache for ISR

The in-memory Map used in this tutorial works for a single process. In production with multiple server instances, use Redis, Memcached, or a database to share the ISR cache across instances.

5. Stream Large Pages

For pages with heavy content, replace renderToString with renderToPipeableStream. Streaming sends HTML chunks to the browser as they're generated, improving Time to First Byte (TTFB) and perceived performance.

6. Handle Errors Gracefully

Wrap render calls in try-catch blocks and provide fallback HTML. If SSR fails, consider falling back to client-side rendering so users still see content rather than a 500 error.

7. Code-Split for Performance

Use React.lazy and Suspense on the client, and loadable-components or React 18's renderToPipeableStream with Suspense on the server to code-split routes and reduce bundle sizes.

Conclusion

Server-side rendering with Webpack gives you fine-grained control over how your application delivers HTML to users. By understanding the mechanics behind SSR, SSG, and ISR, you can choose the right strategy for each route: SSG for static content that rarely changes, ISR for pages that update periodically without sacrificing speed, and SSR for personalized, real-time data. The patterns shown here form the foundation that frameworks like Next.js build upon, and mastering them will make you a more effective developer whether you're building a custom framework or debugging a complex production application. Start with the simplest strategy that meets your needs, measure performance, and adopt more sophisticated techniques like ISR and streaming only when the data demands it.

— Ad —

Google AdSense will appear here after approval

← Back to all articles