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 postcssCreate 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.jsonBuilding 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.jsexports a function that turns a route into an HTML string. In a real framework, this would callrenderToStringfrom React orrenderfrom 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
renderToStringand runs in Node. The client bundle importsentry-client.jsand 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
renderToStringfor 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 ssrand visithttp://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. Thebuild/staticdirectory now containsindex.html,about/index.html, andblog/hello-world/index.html. Serve it with any static file server:npx serve build/staticImplementing 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 ssgfirst to seed the cache, thennpm 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
getDatafunction 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. Thereplace(/</g, '\\u003c')pattern inwrapDocumenthandles this. - Use environment-specific Rollup configs. The server bundle should set
browser: falsein the resolve plugin and externalize Node built-ins. The client bundle should setbrowser: trueand 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.writechunks.
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.