← Back to DevBytes

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

Server-Side Rendering with RESTify: SSR, SSG, and ISR Explained

RESTify is a Node.js framework originally built for building correct REST web services, but it also serves as a lightweight, performant foundation for rendering HTML on the server. While most developers associate SSR with frameworks like Next.js or Nuxt, RESTify's minimal middleware pipeline and predictable routing make it an excellent choice when you want full control over how, when, and where your 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 implement each one using RESTify.

Why Rendering Strategy Matters

The way you deliver HTML to the browser directly affects performance, SEO, and infrastructure cost. A purely client-side rendered app sends an empty shell and fetches data after JavaScript loads, which hurts time-to-first-byte and search indexing. Server-side strategies shift that work to the server, producing meaningful HTML before the browser even parses JavaScript. Choosing between SSR, SSG, and ISR is essentially choosing where and when that HTML is produced.

Project Setup

Start by initializing a new Node project and installing RESTify along with a templating engine. We'll use ejs for templating because it integrates cleanly with RESTify's response API.

mkdir restify-ssr-demo && cd restify-ssr-demo
npm init -y
npm install restify ejs
mkdir -p views public cache

Create a base server file server.js that wires up RESTify, static file serving, and the EJS view engine.

const restify = require('restify');
const path = require('path');
const fs = require('fs').promises;

const server = restify.createServer({ name: 'ssr-demo' });

server.use(restify.plugins.queryParser());
server.use(restify.plugins.bodyParser());
server.use(restify.plugins.gzipResponse());

// Static assets
server.get('/assets/*', restify.plugins.serveStatic({
  directory: path.join(__dirname, 'public'),
}));

// View engine helper
server.renderView = async (res, view, data = {}) => {
  const ejs = require('ejs');
  const file = path.join(__dirname, 'views', `${view}.ejs`);
  const html = await ejs.renderFile(file, data, { async: true });
  res.setHeader('Content-Type', 'text/html');
  res.sendRaw(html);
};

module.exports = server;

This setup gives us a reusable renderView helper that all three strategies will use. The sendRaw method is important because it bypasses RESTify's default JSON serialization.

Implementing Server-Side Rendering (SSR)

SSR generates HTML on every incoming request. This is the right choice when content depends on the user, the request, or live data that changes constantly. The trade-off is that every request pays the cost of rendering, so caching at the edge or CDN is often layered on top.

Let's create a route that fetches a product from a mock API and renders it server-side.

// views/product.ejs
<!DOCTYPE html>
<html>
<head>
  <title><%= product.name %></title>
</head>
<body>
  <h1><%= product.name %></h1>
  <p>Price: $<%= product.price %></p>
  <p><%= product.description %></p>
  <p><small>Rendered at <%= renderedAt %></small></p>
</body>
</html>
// routes/ssr.js
const server = require('../server');

async function fetchProduct(id) {
  // Simulated upstream API call
  return {
    id,
    name: `Product ${id}`,
    price: (Math.random() * 100).toFixed(2),
    description: 'A dynamically rendered product page.'
  };
}

server.get('/products/:id', async (req, res, next) => {
  try {
    const product = await fetchProduct(req.params.id);
    await server.renderView(res, 'product', {
      product,
      renderedAt: new Date().toISOString()
    });
    next();
  } catch (err) {
    next(err);
  }
});

Every request to /products/42 triggers a fresh fetch and render. The timestamp in the footer will change on each reload, confirming the page is being regenerated per request.

Adding a Cache Layer to SSR

Because SSR runs on every request, you should cache expensive upstream calls. A simple in-memory cache with a short TTL works well for most cases.

const cache = new Map();
const TTL = 5000; // 5 seconds

async function fetchProductCached(id) {
  const key = `product:${id}`;
  const hit = cache.get(key);
  if (hit && Date.now() - hit.ts < TTL) {
    return hit.data;
  }
  const data = await fetchProduct(id);
  cache.set(key, { data, ts: Date.now() });
  return data;
}

This keeps SSR responsive while still ensuring data freshness within the TTL window.

Implementing Static Site Generation (SSG)

SSG renders pages once at build time and serves the resulting HTML files for every subsequent request. This is the fastest strategy at runtime because the server only has to return a file. It's ideal for marketing pages, documentation, and blogs.

We'll create a build script that pre-renders a list of articles into static HTML files, then serve them with RESTify's static plugin.

// views/article.ejs
<!DOCTYPE html>
<html>
<head>
  <title><%= article.title %></title>
</head>
<body>
  <article>
    <h1><%= article.title %></h1>
    <p><%= article.body %></p>
    <p><small>Generated at build: <%= builtAt %></small></p>
  </article>
</body>
</html>
// build.js
const ejs = require('ejs');
const fs = require('fs').promises;
const path = require('path');

async function getArticles() {
  return [
    { slug: 'intro-to-ssr', title: 'Intro to SSR', body: 'SSR renders on every request.' },
    { slug: 'ssg-basics', title: 'SSG Basics', body: 'SSG renders once at build time.' },
    { slug: 'isr-explained', title: 'ISR Explained', body: 'ISR revalidates in the background.' }
  ];
}

async function build() {
  const articles = await getArticles();
  const outDir = path.join(__dirname, 'public', 'articles');
  await fs.mkdir(outDir, { recursive: true });

  const builtAt = new Date().toISOString();

  for (const article of articles) {
    const html = await ejs.renderFile(
      path.join(__dirname, 'views', 'article.ejs'),
      { article, builtAt },
      { async: true }
    );
    const outFile = path.join(outDir, `${article.slug}.html`);
    await fs.writeFile(outFile, html, 'utf8');
    console.log(`Wrote ${outFile}`);
  }
}

build().catch(console.error);

Run the build with node build.js. Now register a route that serves the pre-built files.

// routes/ssg.js
const server = require('../server');
const restify = require('restify');
const path = require('path');

server.get('/articles/:slug', (req, res, next) => {
  const file = `${req.params.slug}.html`;
  restify.plugins.serveStatic({
    directory: path.join(__dirname, '..', 'public', 'articles'),
    file
  })(req, res, next);
});

Visiting /articles/ssg-basics returns the pre-rendered HTML instantly. The timestamp stays the same across reloads because the file was generated at build time.

Implementing Incremental Static Regeneration (ISR)

ISR combines the speed of SSG with the freshness of SSR. The first request serves a cached static page. After a configurable revalidation window expires, the next request triggers a background regeneration while still serving the stale page. Subsequent requests then receive the freshly generated content.

This is the most complex strategy, but it's also the most versatile. Let's implement an ISR cache that stores rendered HTML on disk.

// lib/isr-cache.js
const fs = require('fs').promises;
const path = require('path');

const CACHE_DIR = path.join(__dirname, '..', 'cache');

async function ensureDir() {
  await fs.mkdir(CACHE_DIR, { recursive: true });
}

async function readCache(key) {
  try {
    const meta = JSON.parse(
      await fs.readFile(path.join(CACHE_DIR, `${key}.json`), 'utf8')
    );
    const html = await fs.readFile(path.join(CACHE_DIR, `${key}.html`), 'utf8');
    return { html, generatedAt: meta.generatedAt };
  } catch {
    return null;
  }
}

async function writeCache(key, html) {
  await ensureDir();
  await fs.writeFile(path.join(CACHE_DIR, `${key}.html`), html, 'utf8');
  await fs.writeFile(
    path.join(CACHE_DIR, `${key}.json`),
    JSON.stringify({ generatedAt: Date.now() }),
    'utf8'
  );
}

module.exports = { readCache, writeCache };

Now create an ISR middleware factory that wraps any rendering function.

// lib/isr.js
const { readCache, writeCache } = require('./isr-cache');

function isr(revalidateMs, renderFn) {
  return async (req, res, next) => {
    const key = req.path();
    const cached = await readCache(key);

    if (cached) {
      // Serve stale content immediately
      res.setHeader('Content-Type', 'text/html');
      res.setHeader('X-ISR-Cache', 'HIT');
      res.sendRaw(cached.html);

      // Revalidate in the background if stale
      if (Date.now() - cached.generatedAt > revalidateMs) {
        renderFn(req).then(html => writeCache(key, html)).catch(console.error);
      }
      return next();
    }

    // No cache: render synchronously and store
    try {
      const html = await renderFn(req);
      await writeCache(key, html);
      res.setHeader('Content-Type', 'text/html');
      res.setHeader('X-ISR-Cache', 'MISS');
      res.sendRaw(html);
      next();
    } catch (err) {
      next(err);
    }
  };
}

module.exports = isr;

Use the ISR middleware on a news route that pulls live headlines.

// routes/isr.js
const server = require('../server');
const ejs = require('ejs');
const path = require('path');
const isr = require('../lib/isr');

async function fetchNews() {
  return [
    { title: 'RESTify hits v11', body: 'New release improves performance.' },
    { title: 'ISR gains traction', body: 'Developers love stale-while-revalidate.' }
  ];
}

async function renderNewsPage() {
  const news = await fetchNews();
  return ejs.renderFile(
    path.join(__dirname, '..', 'views', 'news.ejs'),
    { news, renderedAt: new Date().toISOString() },
    { async: true }
  );
}

// Revalidate every 60 seconds
server.get('/news', isr(60 * 1000, renderNewsPage));
<!-- views/news.ejs -->
<!DOCTYPE html>
<html>
<head><title>Latest News</title></head>
<body>
  <h1>Latest News</h1>
  <ul>
    <% news.forEach(item => { %>
      <li><strong><%= item.title %></strong>: <%= item.body %></li>
    <% }) %>
  </ul>
  <p><small>Generated: <%= renderedAt %></small></p>
</body>
</html>

The first request renders and caches the page. For the next 60 seconds, every request is served from cache instantly. The first request after 60 seconds still gets the cached version, but triggers a background regeneration. The request after that receives the updated page. Check the X-ISR-Cache header to confirm behavior.

Wiring Everything Together

Create an entry point that loads all route modules and starts the server.

// index.js
const server = require('./server');

require('./routes/ssr');
require('./routes/ssg');
require('./routes/isr');

server.listen(8080, () => {
  console.log(`${server.name} listening at ${server.url}`);
});

Run node build.js && node index.js and test each endpoint:

Best Practices

When implementing these rendering strategies with RESTify, keep the following guidelines in mind.

Choose the Right Strategy Per Route

Not every page needs the same approach. A product page with live inventory should use SSR with a short cache. A privacy policy page should use SSG. A blog post that updates occasionally is a perfect ISR candidate. Mixing strategies within a single app is not only acceptable — it's the recommended pattern.

Always Handle Errors Gracefully

SSR and ISR both depend on upstream data. If the upstream fails, you should serve stale cache when available rather than showing a 500. Modify the ISR middleware to fall back to cached HTML when regeneration throws.

renderFn(req)
  .then(html => writeCache(key, html))
  .catch(err => console.error(`ISR revalidation failed for ${key}:`, err));

Use ETags and Cache-Control Headers

RESTify supports conditional responses natively. Set Cache-Control headers on SSG routes so CDNs can cache aggressively, and use stale-while-revalidate directives on ISR routes to communicate intent to downstream caches.

res.setHeader('Cache-Control', 's-maxage=60, stale-while-revalidate=300');

Persist ISR Cache to Durable Storage

The disk-based cache in this tutorial works for a single instance. In production with multiple server processes or containers, move the cache to Redis or a shared filesystem so regeneration work isn't duplicated across instances.

Measure Rendering Time

Wrap your render calls with timing logs to catch performance regressions early. SSR pages that take more than 200ms to render will hurt user experience and should be converted to ISR or SSG where possible.

const start = Date.now();
const html = await renderFn(req);
console.log(`Render took ${Date.now() - start}ms`);

Conclusion

RESTify's lean middleware architecture makes it a surprisingly capable foundation for server-side rendering. By implementing SSR for dynamic pages, SSG for static content, and ISR for periodically updated pages, you get the flexibility of a modern meta-framework without the abstraction overhead. The key is to match each route to the rendering strategy that best fits its data freshness requirements, layer in caching at the appropriate level, and monitor rendering performance as your traffic grows. With the patterns in this tutorial, you can build a production-grade rendering pipeline on top of RESTify that delivers fast, SEO-friendly HTML while keeping your infrastructure costs predictable.

— Ad —

Google AdSense will appear here after approval

← Back to all articles