← Back to DevBytes

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

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

Rollup is best known as a bundler for libraries and small applications, but with the right plugins and architecture, it can power a fully-featured rendering pipeline that supports Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR). This tutorial walks through building a minimal but production-capable framework on top of Rollup that handles all three rendering strategies.

Why Use Rollup for Rendering?

Most developers reach for Next.js, Nuxt, or SvelteKit when they need SSR. These frameworks are excellent, but they hide the underlying mechanics. Building the same pipeline with Rollup teaches you exactly how rendering strategies differ, how the server and client share code, and how to ship only what each environment needs. Rollup's tree-shaking, code-splitting, and plugin ecosystem make it a strong foundation for custom frameworks or specialized applications where off-the-shelf solutions are too opinionated.

Understanding the Three Rendering Strategies

Server-Side Rendering (SSR)

SSR generates HTML on the server for every request. The browser receives a fully-rendered page, which improves first contentful paint and SEO. The trade-off is that every request requires server compute time, and the server must stay running.

Static Site Generation (SSG)

SSG pre-renders pages to HTML files at build time. The output is a set of static files you can serve from any CDN. This gives you the best performance and the cheapest hosting, but content only updates when you rebuild the site.

Incremental Static Regeneration (ISR)

ISR bridges the gap between SSG and SSR. Pages are generated statically, but a background process regenerates them when they become stale or on-demand. The user always gets a fast static page, while content stays fresh. ISR requires a persistent server or serverless function that can write to your static file store.

Project Setup

Start by initializing a project and installing the core dependencies.

mkdir rollup-ssr && cd rollup-ssr
npm init -y
npm install rollup @rollup/plugin-node-resolve @rollup/plugin-replace \
  @rollup/plugin-commonjs rollup-plugin-postcss express
npm install -D postcss


Create the following directory structure:

rollup-ssr/
├── src/
│   ├── App.js
│   ├── router.js
│   ├── render.js
│   └── entry-client.js
├── server/
│   └── index.js
├── scripts/
│   ├── build-ssg.js
│   └── isr.js
├── public/
├── rollup.config.client.js
├── rollup.config.server.js
└── package.json


Building the Universal App Shell

The key to supporting SSR, SSG, and ISR from one codebase is a universal app shell that can render to a string on the server and hydrate on the client. We will use a minimal virtual DOM approach to keep dependencies light, but the same pattern works with React, Preact, Vue, or Svelte.

The Render Function

src/render.js exports a function that turns a route into an HTML string. In a real framework, this would call renderToString from React or render from Svelte. Here we keep it simple.

// src/render.js
import { matchRoute } from './router.js';
import App from './App.js';

export function renderToString(url, initialState = {}) {
  const route = matchRoute(url);
  if (!route) {
    return { html: '

404

Not found

', status: 404, state: {} }; } const { html, state } = App.render({ route, url, state: initialState }); return { html, status: 200, state }; } export function wrapDocument(html, state, assets) { return `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Rollup SSR</title> ${assets.css.map(c => `<link rel="stylesheet" href="${c}">`).join('\n ')} </head> <body> <div id="app">${html}</div> <script>window.__INITIAL_STATE__ = ${JSON.stringify(state).replace(/</g, '\\u003c')}</script> ${assets.js.map(j => `<script type="module" src="${j}"></script>`).join('\n ')} </body> </html>`; }

The Router

// src/router.js
const routes = [
  { path: '/', component: 'Home', getData: async () => ({ title: 'Welcome', items: ['A', 'B', 'C'] }) },
  { path: '/about', component: 'About', getData: async () => ({ title: 'About Us' }) },
  { path: '/blog/:slug', component: 'BlogPost', getData: async (params) => ({ slug: params.slug, title: `Post ${params.slug}` }) },
];

export function matchRoute(url) {
  const pathname = url.split('?')[0];
  for (const route of routes) {
    const pattern = new RegExp('^' + route.path.replace(/:[^/]+/g, '([^/]+)') + '$');
    const match = pathname.match(pattern);
    if (match) {
      const params = {};
      const paramNames = route.path.match(/:[^/]+/g) || [];
      paramNames.forEach((name, i) => {
        params[name.slice(1)] = match[i + 1];
      });
      return { ...route, params };
    }
  }
  return null;
}

export function listStaticPaths() {
  // For SSG: return all paths that should be pre-rendered.
  // Dynamic routes would query a CMS or database here.
  return ['/', '/about', '/blog/hello-world', '/blog/second-post'];
}


The App Component

// src/App.js
export default {
  render({ route, state }) {
    const data = state.data || {};
    let html = '';

    switch (route.component) {
      case 'Home':
        html = `<h1>${data.title || 'Home'}</h1><ul>${(data.items || []).map(i => `<li>${i}</li>`).join('')}</ul>`;
        break;
      case 'About':
        html = `<h1>${data.title || 'About'}</h1><p>This is the about page.</p>`;
        break;
      case 'BlogPost':
        html = `<article><h1>${data.title || 'Post'}</h1><p>Slug: ${data.slug}</p></article>`;
        break;
      default:
        html = '<h1>404</h1>';
    }

    return { html, state: { data, route: route.path } };
  },
};


The Client Entry

// src/entry-client.js
import { matchRoute } from './router.js';
import App from './App.js';

function hydrate() {
  const state = window.__INITIAL_STATE__ || {};
  const url = window.location.pathname;
  const route = matchRoute(url);
  if (!route) return;

  const { html } = App.render({ route, url, state });
  const container = document.getElementById('app');
  // In a real app, hydrate instead of replacing innerHTML.
  // This example re-renders for simplicity.
  container.innerHTML = html;

  // Client-side navigation
  document.addEventListener('click', (e) => {
    const link = e.target.closest('a');
    if (!link || link.target === '_blank' || link.hasAttribute('data-external')) return;
    const href = link.getAttribute('href');
    if (!href || !href.startsWith('/')) return;
    e.preventDefault();
    history.pushState({}, '', href);
    navigate(href);
  });

  window.addEventListener('popstate', () => navigate(window.location.pathname));
}

async function navigate(url) {
  const route = matchRoute(url);
  if (!route) return;
  // Fetch fresh data on client navigation
  const data = await route.getData(route.params);
  const { html } = App.render({ route, url, state: { data } });
  document.getElementById('app').innerHTML = html;
}

hydrate();


Rollup Configuration

You need two Rollup builds: one for the server bundle and one for the client bundle. The server bundle imports renderToString and runs in Node. The client bundle imports entry-client.js and runs in the browser.

Server Config

// rollup.config.server.js
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import replace from '@rollup/plugin-replace';

export default {
  input: 'src/render.js',
  output: {
    dir: 'build/server',
    format: 'esm',
    entryFileNames: 'render.js',
  },
  plugins: [
    replace({
      'process.env.NODE_ENV': JSON.stringify('production'),
      preventAssignment: true,
    }),
    resolve({ browser: false, preferBuiltins: true }),
    commonjs(),
  ],
  external: ['fs', 'path', 'stream'],
};


Client Config

// rollup.config.client.js
import resolve from '@rollup/plugin-node-resolve';
import replace from '@rollup/plugin-replace';
import postcss from 'rollup-plugin-postcss';

export default {
  input: 'src/entry-client.js',
  output: {
    dir: 'build/client',
    format: 'esm',
    entryFileNames: 'assets/[name]-[hash].js',
    chunkFileNames: 'assets/[name]-[hash].js',
    assetFileNames: 'assets/[name]-[hash][extname]',
  },
  plugins: [
    replace({
      'process.env.NODE_ENV': JSON.stringify('production'),
      preventAssignment: true,
    }),
    resolve({ browser: true }),
    postcss({ extract: true, minimize: true }),
  ],
};


Add scripts to package.json:

{
  "scripts": {
    "build:client": "rollup -c rollup.config.client.js",
    "build:server": "rollup -c rollup.config.server.js",
    "build": "npm run build:client && npm run build:server",
    "ssr": "npm run build && node server/index.js",
    "ssg": "npm run build && node scripts/build-ssg.js",
    "isr": "npm run build && node scripts/isr.js"
  }
}


Implementing SSR

With the bundles built, the SSR server imports the server bundle, calls renderToString for each request, fetches route data, and wraps the result in the HTML document.

// server/index.js
import express from 'express';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { renderToString, wrapDocument } from '../build/server/render.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 3000;

// Serve client assets
app.use('/assets', express.static(path.join(__dirname, '..', 'build', 'client', 'assets')));

// Collect built asset filenames
function getAssets() {
  const assetsDir = path.join(__dirname, '..', 'build', 'client', 'assets');
  const files = fs.existsSync(assetsDir) ? fs.readdirSync(assetsDir) : [];
  return {
    js: files.filter(f => f.endsWith('.js')),
    css: files.filter(f => f.endsWith('.css')),
  };
}

app.get('*', async (req, res) => {
  try {
    // Import router dynamically to call getData
    const { matchRoute } = await import('../build/server/router.js');
    const route = matchRoute(req.url);
    if (!route) {
      const { html } = renderToString(req.url);
      return res.status(404).send(wrapDocument(html, {}, getAssets()));
    }

    const data = await route.getData(route.params);
    const { html, state } = renderToString(req.url, { data });
    const document = wrapDocument(html, state, getAssets());
    res.status(200).send(document);
  } catch (err) {
    console.error('SSR error:', err);
    res.status(500).send('Internal Server Error');
  }
});

app.listen(PORT, () => {
  console.log(`SSR server running at http://localhost:${PORT}`);
});


Run npm run ssr and visit http://localhost:3000. Every request triggers a fresh render with up-to-date data. This is ideal for highly dynamic content, personalized pages, and authenticated routes.

Implementing SSG

SSG runs the same render pipeline at build time, writing one HTML file per route. The output is a folder of static files you can deploy to any CDN.

// scripts/build-ssg.js
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { renderToString, wrapDocument } from '../build/server/render.js';
import { matchRoute, listStaticPaths } from '../build/server/router.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outDir = path.join(__dirname, '..', 'build', 'static');

function getAssets() {
  const assetsDir = path.join(__dirname, '..', 'build', 'client', 'assets');
  const files = fs.existsSync(assetsDir) ? fs.readdirSync(assetsDir) : [];
  return {
    js: files.filter(f => f.endsWith('.js')).map(f => `/assets/${f}`),
    css: files.filter(f => f.endsWith('.css')).map(f => `/assets/${f}`),
  };
}

async function generate() {
  fs.rmSync(outDir, { recursive: true, force: true });
  fs.mkdirSync(outDir, { recursive: true });

  // Copy client assets
  const clientAssetsDir = path.join(__dirname, '..', 'build', 'client', 'assets');
  if (fs.existsSync(clientAssetsDir)) {
    fs.cpSync(clientAssetsDir, path.join(outDir, 'assets'), { recursive: true });
  }

  const assets = getAssets();
  const paths = listStaticPaths();

  for (const urlPath of paths) {
    const route = matchRoute(urlPath);
    if (!route) {
      console.warn(`No route for ${urlPath}, skipping.`);
      continue;
    }

    const data = await route.getData(route.params);
    const { html, state } = renderToString(urlPath, { data });
    const document = wrapDocument(html, state, assets);

    // Determine output file path
    const isIndex = urlPath === '/';
    const filePath = isIndex
      ? path.join(outDir, 'index.html')
      : path.join(outDir, urlPath, 'index.html');

    fs.mkdirSync(path.dirname(filePath), { recursive: true });
    fs.writeFileSync(filePath, document);
    console.log(`Generated: ${filePath}`);
  }

  console.log(`SSG complete: ${paths.length} pages written to ${outDir}`);
}

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


Run npm run ssg. The build/static directory now contains index.html, about/index.html, and blog/hello-world/index.html. Serve it with any static file server:

npx serve build/static


Implementing ISR

ISR combines SSG's static output with on-demand regeneration. The server serves the cached HTML file immediately, then checks whether the page is stale. If it is, the server regenerates the file in the background so the next request gets fresh content. This pattern is called stale-while-revalidate.

// scripts/isr.js
import express from 'express';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { renderToString, wrapDocument } from '../build/server/render.js';
import { matchRoute } from '../build/server/router.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 3000;
const CACHE_DIR = path.join(__dirname, '..', 'build', 'static');
const REVALIDATION_INTERVAL_MS = 60 * 1000; // 1 minute

// In-memory cache of last regeneration timestamps
const lastGenerated = new Map();

function getAssets() {
  const assetsDir = path.join(__dirname, '..', 'build', 'client', 'assets');
  const files = fs.existsSync(assetsDir) ? fs.readdirSync(assetsDir) : [];
  return {
    js: files.filter(f => f.endsWith('.js')).map(f => `/assets/${f}`),
    css: files.filter(f => f.endsWith('.css')).map(f => `/assets/${f}`),
  };
}

function getCachedFile(urlPath) {
  const isIndex = urlPath === '/';
  return isIndex
    ? path.join(CACHE_DIR, 'index.html')
    : path.join(CACHE_DIR, urlPath, 'index.html');
}

async function regenerate(urlPath) {
  const route = matchRoute(urlPath);
  if (!route) return;

  const data = await route.getData(route.params);
  const { html, state } = renderToString(urlPath, { data });
  const document = wrapDocument(html, state, getAssets());

  const filePath = getCachedFile(urlPath);
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  fs.writeFileSync(filePath, document);
  lastGenerated.set(urlPath, Date.now());
  console.log(`Regenerated: ${urlPath}`);
}

app.use('/assets', express.static(path.join(CACHE_DIR, 'assets')));

app.get('*', async (req, res) => {
  const urlPath = req.path;
  const cachedFile = getCachedFile(urlPath);

  // Serve cached version if it exists
  if (fs.existsSync(cachedFile)) {
    const last = lastGenerated.get(urlPath) || 0;
    const isStale = Date.now() - last > REVALIDATION_INTERVAL_MS;

    if (isStale) {
      // Revalidate in the background — do not block the response
      regenerate(urlPath).catch(err => console.error('Revalidation failed:', err));
    }

    return res.sendFile(cachedFile);
  }

  // No cache yet — generate on the fly (first request)
  try {
    await regenerate(urlPath);
    return res.sendFile(cachedFile);
  } catch (err) {
    console.error('On-demand generation failed:', err);
    return res.status(500).send('Generation failed');
  }
});

app.listen(PORT, () => {
  console.log(`ISR server running at http://localhost:${PORT}`);
  console.log(`Revalidation interval: ${REVALIDATION_INTERVAL_MS / 1000}s`);
});


Run npm run ssg first to seed the cache, then npm run isr. The first request for any page serves the pre-built HTML. After the revalidation interval passes, the next request still serves the stale file instantly but triggers a background regeneration. Subsequent requests receive the updated content. This gives you CDN-like performance with dynamic freshness.

Best Practices

  • Keep data fetching separate from rendering. The getData function on each route lets you reuse the same logic across SSR, SSG, and ISR without duplicating fetch code.
  • Serialize state safely. Always escape < characters in JSON injected into HTML to prevent XSS through script injection. The replace(/</g, '\\u003c') pattern in wrapDocument handles this.
  • Use environment-specific Rollup configs. The server bundle should set browser: false in the resolve plugin and externalize Node built-ins. The client bundle should set browser: true and avoid bundling Node modules.
  • Hash your client assets. The [hash] placeholder in the client output filenames ensures cache-busting when content changes, which is critical for SSG and ISR deployments behind a CDN.
  • Handle errors gracefully in ISR. If background regeneration fails, the stale cached file should still be served. Never block a user request on regeneration.
  • Choose the right strategy per route. Marketing pages are perfect for SSG. Product catalogs with frequent updates benefit from ISR. User dashboards and authenticated pages should use SSR. A single site can mix all three.
  • Test hydration. Mismatches between server-rendered HTML and client-rendered output cause hydration errors. Ensure the client uses the same initial state passed from the server via window.__INITIAL_STATE__.
  • Consider streaming for SSR. For large pages, streaming HTML to the browser as it renders improves time to first byte. Express supports this with res.write chunks.

Conclusion

Rollup is a capable foundation for a custom rendering pipeline that supports SSR, SSG, and ISR. By splitting your build into server and client bundles, sharing a universal app shell, and routing data fetching through a single getData interface, you can switch between rendering strategies without rewriting application code. SSR gives you real-time personalization, SSG gives you maximum performance and cheap hosting, and ISR gives you the best of both with stale-while-revalidate semantics. Start with the simplest strategy your content demands, then layer in ISR or SSR only where freshness or personalization requires it. The architecture above scales from a static blog to a full dynamic application, all powered by the same Rollup-based toolchain.

— Ad —

Google AdSense will appear here after approval

← Back to all articles