Server-Side Rendering with Express: SSR, SSG, ISR
Modern web applications demand fast initial page loads, SEO-friendly content, and dynamic interactivity. While Single Page Applications (SPAs) dominated the 2010s, the pendulum has swung back toward rendering HTML on the server. Express, the most popular Node.js web framework, gives you fine-grained control over how and when HTML is generated. In this tutorial, we'll explore three rendering strategies — Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR) — and how to implement each one with Express.
What Are SSR, SSG, and ISR?
These three strategies describe when and where HTML is generated. Understanding the differences is the foundation for choosing the right approach for each route in your application.
- SSR (Server-Side Rendering): HTML is generated on the server on every request. The server fetches fresh data, renders the template, and sends a complete HTML document to the browser. Best for highly dynamic, personalized, or frequently changing content.
- SSG (Static Site Generation): HTML is generated once at build time and served as static files. Best for content that rarely changes — marketing pages, blog posts, documentation.
- ISR (Incremental Static Regeneration): A hybrid approach. Static HTML is served immediately, but the server periodically regenerates the page in the background when the cached version becomes stale. Best for content that updates occasionally but doesn't need to be real-time.
Why It Matters
Choosing the right rendering strategy affects three critical metrics: Time to First Byte (TTFB), SEO crawlability, and server cost. SSR gives you fresh data but adds latency per request. SSG gives you the fastest possible response and can be served from a CDN, but requires a rebuild when content changes. ISR sits in the middle, offering near-static performance with eventual freshness. A well-architected application often uses all three strategies on different routes.
Setting Up the Project
Let's start by creating a minimal Express application with a templating engine. We'll use EJS because it's simple and widely understood, but the concepts apply to any template engine.
mkdir express-rendering
cd express-rendering
npm init -y
npm install express ejs
npm install --save-dev nodemon
Create the following directory structure:
express-rendering/
├── views/
│ ├── layout.ejs
│ ├── home.ejs
│ ├── post.ejs
│ └── product.ejs
├── public/
│ └── styles.css
├── cache/
├── server.js
└── package.json
Here is the base Express server we'll build upon:
// server.js
const express = require('express');
const path = require('path');
const app = express();
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(express.static(path.join(__dirname, 'public')));
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
module.exports = app;
Implementing SSR (Server-Side Rendering)
With SSR, every request triggers a fresh render. The server fetches data, passes it to the template, and returns HTML. This is the most straightforward approach and the default mental model for server-rendered applications.
A Basic SSR Route
// server.js (add before app.listen)
const fetch = require('node-fetch');
// Simulated data source
async function getProduct(id) {
const res = await fetch(`https://api.example.com/products/${id}`);
return res.json();
}
app.get('/products/:id', async (req, res, next) => {
try {
const product = await getProduct(req.params.id);
res.render('product', { product, generatedAt: new Date().toISOString() });
} catch (err) {
next(err);
}
});
The corresponding template:
<!-- views/product.ejs -->
<%- include('layout', { title: product.name }) %>
<article>
<h1><%= product.name %></h1>
<p class="price">$<%= product.price %></p>
<p><%= product.description %></p>
<small>Rendered at <%= generatedAt %></small>
</article>
Adding In-Memory Caching to SSR
Pure SSR can be expensive if your data source is slow. A short-lived cache reduces database or API load while keeping content reasonably fresh. Here's a simple time-based cache wrapper:
// cache.js
const cache = new Map();
function withCache(ttlSeconds, fn) {
return async function (...args) {
const key = JSON.stringify(args);
const entry = cache.get(key);
const now = Date.now();
if (entry && now - entry.timestamp < ttlSeconds * 1000) {
return entry.value;
}
const value = await fn(...args);
cache.set(key, { value, timestamp: now });
return value;
};
}
module.exports = { withCache, cache };
Use it to wrap your data fetcher:
const { withCache } = require('./cache');
const getProductCached = withCache(10, getProduct);
app.get('/products/:id', async (req, res, next) => {
try {
const product = await getProductCached(req.params.id);
res.render('product', { product, generatedAt: new Date().toISOString() });
} catch (err) {
next(err);
}
});
Now the product data is cached for 10 seconds. The HTML is still rendered on every request, but the expensive data fetch happens at most once per TTL window. This is a pragmatic middle ground — you get fresh HTML without hammering your data source.
Implementing SSG (Static Site Generation)
SSG pre-renders pages at build time. The output is plain HTML files that Express serves as static assets. This gives you the best possible performance because no template rendering happens at request time.
A Build Script for Static Generation
Create a separate script that renders templates to files:
// build.js
const fs = require('fs');
const path = require('path');
const ejs = require('ejs');
const fetch = require('node-fetch');
const VIEWS_DIR = path.join(__dirname, 'views');
const OUTPUT_DIR = path.join(__dirname, 'public', 'generated');
async function getAllPosts() {
const res = await fetch('https://api.example.com/posts');
return res.json();
}
async function getPost(slug) {
const res = await fetch(`https://api.example.com/posts/${slug}`);
return res.json();
}
async function build() {
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
// Generate home page
const posts = await getAllPosts();
const homeHtml = await ejs.renderFile(
path.join(VIEWS_DIR, 'home.ejs'),
{ posts, generatedAt: new Date().toISOString() }
);
fs.writeFileSync(path.join(OUTPUT_DIR, 'index.html'), homeHtml);
console.log('Generated index.html');
// Generate each post page
for (const post of posts) {
const fullPost = await getPost(post.slug);
const html = await ejs.renderFile(
path.join(VIEWS_DIR, 'post.ejs'),
{ post: fullPost, generatedAt: new Date().toISOString() }
);
const postDir = path.join(OUTPUT_DIR, 'posts', post.slug);
fs.mkdirSync(postDir, { recursive: true });
fs.writeFileSync(path.join(postDir, 'index.html'), html);
console.log(`Generated posts/${post.slug}/index.html`);
}
console.log('Build complete.');
}
build().catch(console.error);
Add a build script to package.json:
{
"scripts": {
"build": "node build.js",
"start": "node server.js",
"dev": "nodemon server.js"
}
}
Run npm run build to generate static files, then start the server. Express already serves the public directory, so the generated HTML is immediately available at routes like /generated/posts/my-post/.
Clean URLs for Static Pages
Static file serving produces URLs with directory paths. To get clean URLs like /posts/my-post, add a fallback route that serves the generated file if it exists:
// server.js
app.get('/posts/:slug', (req, res, next) => {
const filePath = path.join(__dirname, 'public', 'generated', 'posts', req.params.slug, 'index.html');
if (fs.existsSync(filePath)) {
res.sendFile(filePath);
} else {
next();
}
});
This route checks for a pre-built static file first. If it exists, Express sends it directly — no template rendering, no data fetching. If it doesn't exist, the request falls through to the next handler, which could be an SSR route as a fallback.
Implementing ISR (Incremental Static Regeneration)
ISR combines the speed of SSG with the freshness of SSR. The first request serves a static (possibly stale) page. In the background, the server checks whether the page is older than a configured staleness threshold. If it is, the server regenerates the page and replaces the cached version. Subsequent requests get the freshly generated page.
Express doesn't have built-in ISR like Next.js does, but it's straightforward to implement with a small middleware. The key idea: serve from cache, and trigger a background rebuild when the cache is stale.
An ISR Middleware for Express
// isr.js
const fs = require('fs');
const path = require('path');
const ejs = require('ejs');
const CACHE_DIR = path.join(__dirname, 'cache');
if (!fs.existsSync(CACHE_DIR)) {
fs.mkdirSync(CACHE_DIR, { recursive: true });
}
/**
* Creates an ISR-style route handler.
* @param {string} template - Name of the EJS template file
* @param {Function} getData - Async function returning data for the template
* @param {number} revalidateSeconds - How long before the page is considered stale
*/
function isrRoute(template, getData, revalidateSeconds) {
return async (req, res, next) => {
try {
const cacheKey = req.path;
const cacheFile = path.join(CACHE_DIR, Buffer.from(cacheKey).toString('hex') + '.html');
const metaFile = cacheFile + '.json';
let cachedHtml = null;
let meta = null;
if (fs.existsSync(cacheFile) && fs.existsSync(metaFile)) {
cachedHtml = fs.readFileSync(cacheFile, 'utf-8');
meta = JSON.parse(fs.readFileSync(metaFile, 'utf-8'));
}
const now = Date.now();
const isStale = !meta || (now - meta.generatedAt) > revalidateSeconds * 1000;
// Serve stale content immediately if available
if (cachedHtml) {
res.send(cachedHtml);
// If stale, regenerate in the background (non-blocking)
if (isStale) {
regenerateInBackground(req, template, getData, cacheFile, metaFile);
}
return;
}
// No cache yet — render synchronously (first request is slow)
const data = await getData(req);
const html = await ejs.renderFile(
path.join(__dirname, 'views', template),
{ ...data, generatedAt: new Date().toISOString() }
);
fs.writeFileSync(cacheFile, html);
fs.writeFileSync(metaFile, JSON.stringify({ generatedAt: now }));
res.send(html);
} catch (err) {
next(err);
}
};
}
async function regenerateInBackground(req, template, getData, cacheFile, metaFile) {
try {
const data = await getData(req);
const html = await ejs.renderFile(
path.join(__dirname, 'views', template),
{ ...data, generatedAt: new Date().toISOString() }
);
// Write to a temp file first, then rename atomically
const tmpFile = cacheFile + '.tmp';
fs.writeFileSync(tmpFile, html);
fs.renameSync(tmpFile, cacheFile);
fs.writeFileSync(metaFile, JSON.stringify({ generatedAt: Date.now() }));
console.log(`ISR: regenerated ${req.path}`);
} catch (err) {
console.error(`ISR: regeneration failed for ${req.path}:`, err.message);
}
}
module.exports = { isrRoute };
Using the ISR Route
// server.js
const { isrRoute } = require('./isr');
async function getPostData(req) {
const slug = req.params.slug;
const res = await fetch(`https://api.example.com/posts/${slug}`);
const post = await res.json();
return { post };
}
// ISR route: serves cached HTML, regenerates every 60 seconds
app.get('/blog/:slug', isrRoute('post.ejs', getPostData, 60));
Here's how this works in practice:
- First request to
/blog/hello-world: No cache exists. The server renders the page synchronously, writes it to disk, and sends it to the client. This first request is as slow as SSR. - Subsequent requests within 60 seconds: The server reads the cached HTML from disk and sends it immediately. Response time is near-instant, comparable to SSG.
- First request after 60 seconds: The server still sends the cached (now stale) HTML immediately for fast response. Simultaneously, it triggers a background regeneration. The next request after regeneration completes gets the fresh version.
This "stale-while-revalidate" pattern means users never wait for a regeneration to complete — they always get a fast response, and freshness improves over time.
Combining All Three Strategies
A real application benefits from using all three strategies on different routes. Here's how you might structure a single Express application:
// server.js
const express = require('express');
const path = require('path');
const fs = require('fs');
const { isrRoute } = require('./isr');
const { withCache } = require('./cache');
const app = express();
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(express.static(path.join(__dirname, 'public')));
// --- SSG: Pre-built static pages ---
// These are served directly from public/generated/ by express.static
// Run `npm run build` to (re)generate them.
// --- ISR: Blog posts that update occasionally ---
app.get('/blog/:slug', isrRoute('post.ejs', getPostData, 60));
// --- SSR: User dashboard (always fresh, personalized) ---
app.get('/dashboard', async (req, res, next) => {
try {
const user = await getCurrentUser(req); // from session/cookie
const stats = await getUserStats(user.id);
res.render('dashboard', { user, stats, generatedAt: new Date().toISOString() });
} catch (err) {
next(err);
}
});
// --- SSR with caching: Product pages (fresh-ish, high traffic) ---
const getProductCached = withCache(30, getProduct);
app.get('/products/:id', async (req, res, next) => {
try {
const product = await getProductCached(req.params.id);
res.render('product', { product, generatedAt: new Date().toISOString() });
} catch (err) {
next(err);
}
});
// --- SSG fallback for marketing pages ---
app.get('/about', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'generated', 'about', 'index.html'));
});
app.listen(3000, () => console.log('Running on http://localhost:3000'));
Best Practices
Choose the Right Strategy Per Route
Don't use one strategy for everything. Audit each route and ask: How often does this data change? Does it need to be personalized? How much traffic does it get? Marketing pages should be SSG. User-specific pages should be SSR. Content pages that update on a schedule are perfect for ISR.
Handle Errors Gracefully
When a background ISR regeneration fails, the old cached version should remain available. The implementation above does this correctly — it only replaces the cache file after successful regeneration. Always write to a temporary file and rename atomically to avoid serving partially written files.
Set Appropriate Cache Headers
Even with server-side caching, HTTP response headers matter. For ISR pages, use stale-while-revalidate directives to let CDNs and browsers cache effectively:
app.get('/blog/:slug', (req, res, next) => {
res.set('Cache-Control', 's-maxage=60, stale-while-revalidate=300');
next();
}, isrRoute('post.ejs', getPostData, 60));
Warm the Cache
ISR's first request is slow because there's no cached version. After deploying, crawl your important URLs to pre-populate the cache. You can also trigger regeneration via a webhook when your CMS publishes new content:
app.post('/api/revalidate', async (req, res) => {
const { secret, slug } = req.body;
if (secret !== process.env.REVALIDATE_SECRET) {
return res.status(401).json({ error: 'Unauthorized' });
}
// Delete the cached file so the next request regenerates it
const cacheFile = path.join(__dirname, 'cache', Buffer.from(`/blog/${slug}`).toString('hex') + '.html');
if (fs.existsSync(cacheFile)) {
fs.unlinkSync(cacheFile);
}
res.json({ revalidated: true });
});
Monitor Cache Hit Rates
Add logging to your ISR and caching middleware to track hit rates. If your ISR pages are regenerating too frequently, increase the revalidation window. If they're always stale, consider switching to SSR or shortening the window.
Use a Persistent Cache for Production
The in-memory and filesystem caches shown here work for single-process deployments. For production with multiple instances, use Redis or a shared filesystem so all instances share the same cache state.
Conclusion
Express gives you the building blocks to implement any rendering strategy without being locked into a framework's opinion. SSR handles dynamic, personalized content with fresh data on every request. SSG delivers maximum performance for content that changes infrequently. ISR bridges the gap with stale-while-revalidate semantics that keep pages fast while staying reasonably fresh. By understanding the tradeoffs of each approach and applying them strategically across your routes, you can build applications that are fast, SEO-friendly, and cost-effective to run. Start with SSR for everything, identify your slowest and most-trafficked routes, then progressively upgrade them to SSG or ISR as your performance requirements demand.