← Back to DevBytes

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

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.

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:

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles