← Back to DevBytes

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

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

Modern web applications face a constant tension between rich client-side interactivity and the performance, SEO, and accessibility benefits of server-rendered HTML. While frameworks like Next.js and Nuxt offer built-in rendering strategies, there are scenarios where you need fine-grained control over how and when pages are rendered. This is where Puppeteer, a Node.js library that controls headless Chrome, becomes a powerful tool for implementing Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR).

In this tutorial, you will learn what each rendering strategy means, why Puppeteer is uniquely suited for them, how to implement each one with practical code, and the best practices that keep your implementation production-ready.

What Is Puppeteer-Based Rendering?

Puppeteer is a high-level API that lets you programmatically control a Chromium browser. Unlike traditional SSR libraries that parse and execute JavaScript on the server (such as React's renderToString), Puppeteer launches an actual browser instance, navigates to a URL, waits for the page to load, and extracts the final DOM as serialized HTML. This approach is sometimes called prerendering or headless rendering.

Why Use Puppeteer Instead of Framework SSR?

The Three Rendering Strategies

Before diving into code, let's clarify the three strategies this tutorial covers:

Setting Up the Project

Start by creating a new Node.js project and installing Puppeteer along with Express for serving requests.

mkdir puppeteer-rendering
cd puppeteer-rendering
npm init -y
npm install puppeteer express

Next, create a shared rendering utility that all three strategies will use. This module launches a browser, navigates to a URL, waits for the page to be ready, and returns the HTML.

// renderer.js
const puppeteer = require('puppeteer');

let browserInstance = null;

async function getBrowser() {
  if (!browserInstance) {
    browserInstance = await puppeteer.launch({
      headless: 'new',
      args: ['--no-sandbox', '--disable-setuid-sandbox'],
    });
  }
  return browserInstance;
}

async function renderPage(url, options = {}) {
  const {
    waitUntil = 'networkidle0',
    timeout = 30000,
    waitForSelector = null,
  } = options;

  const browser = await getBrowser();
  const page = await browser.newPage();

  try {
    await page.setUserAgent('Puppeteer-SSR-Bot/1.0');
    await page.goto(url, { waitUntil, timeout });

    if (waitForSelector) {
      await page.waitForSelector(waitForSelector, { timeout });
    }

    const html = await page.content();
    return html;
  } finally {
    await page.close();
  }
}

module.exports = { renderPage, getBrowser };

The networkidle0 wait condition tells Puppeteer to consider the page loaded when there are no more than zero network connections for at least 500ms. This ensures async data fetching completes before we capture the HTML.

Implementing Server-Side Rendering (SSR)

With SSR, every incoming request triggers a fresh render. This is ideal for highly dynamic content such as user dashboards, real-time data, or personalized pages. The trade-off is that each request incurs the cost of launching a page and waiting for it to load.

// ssr-server.js
const express = require('express');
const { renderPage } = require('./renderer');

const app = express();
const CLIENT_URL = 'http://localhost:3000'; // Your client-side app

app.get('*', async (req, res) => {
  const targetUrl = `${CLIENT_URL}${req.originalUrl}`;

  try {
    const html = await renderPage(targetUrl, {
      waitUntil: 'networkidle0',
      waitForSelector: '#app', // Wait for your root element to populate
    });
    res.set('Content-Type', 'text/html');
    res.send(html);
  } catch (err) {
    console.error('SSR render failed:', err.message);
    res.status(500).send('Rendering error');
  }
});

app.listen(8080, () => {
  console.log('SSR server running on port 8080');
});

Adding Request Caching

Even with SSR, you can cache responses for a short duration to reduce load. A simple in-memory cache with a TTL works well for moderately dynamic content.

// ssr-server-cached.js
const express = require('express');
const { renderPage } = require('./renderer');

const app = express();
const CLIENT_URL = 'http://localhost:3000';
const cache = new Map();
const CACHE_TTL = 10; // seconds

app.get('*', async (req, res) => {
  const cacheKey = req.originalUrl;
  const cached = cache.get(cacheKey);

  if (cached && Date.now() - cached.timestamp < CACHE_TTL * 1000) {
    return res.send(cached.html);
  }

  try {
    const html = await renderPage(`${CLIENT_URL}${cacheKey}`, {
      waitForSelector: '#app',
    });
    cache.set(cacheKey, { html, timestamp: Date.now() });
    res.send(html);
  } catch (err) {
    res.status(500).send('Rendering error');
  }
});

app.listen(8080, () => console.log('Cached SSR server on port 8080'));

Implementing Static Site Generation (SSG)

SSG renders all pages once at build time and writes them to disk as static HTML files. This approach delivers the fastest possible page loads and can be deployed to any static hosting provider. It is best suited for content that changes infrequently, such as blog posts, documentation, or marketing pages.

// ssg-build.js
const fs = require('fs');
const path = require('path');
const { renderPage, getBrowser } = require('./renderer');

const CLIENT_URL = 'http://localhost:3000';
const OUTPUT_DIR = path.join(__dirname, 'dist');

// Example list of routes to prerender
const routes = [
  '/',
  '/about',
  '/blog',
  '/blog/getting-started-with-puppeteer',
  '/blog/ssr-vs-ssg-vs-isr',
];

async function generateSite() {
  if (!fs.existsSync(OUTPUT_DIR)) {
    fs.mkdirSync(OUTPUT_DIR, { recursive: true });
  }

  for (const route of routes) {
    const url = `${CLIENT_URL}${route}`;
    console.log(`Rendering ${url}...`);

    try {
      const html = await renderPage(url, {
        waitUntil: 'networkidle0',
        waitForSelector: '#app',
      });

      // Determine output file path
      const routeDir = route === '/'
        ? OUTPUT_DIR
        : path.join(OUTPUT_DIR, route);

      if (!fs.existsSync(routeDir)) {
        fs.mkdirSync(routeDir, { recursive: true });
      }

      const filePath = path.join(routeDir, 'index.html');
      fs.writeFileSync(filePath, html);
      console.log(`  Saved to ${filePath}`);
    } catch (err) {
      console.error(`  Failed to render ${route}:`, err.message);
    }
  }

  // Close the shared browser instance
  const browser = await getBrowser();
  await browser.close();
  console.log('SSG build complete.');
}

generateSite();

Run this script after starting your client-side dev server. The output in dist/ can then be served by any static file server, CDN, or hosting platform like Netlify or GitHub Pages.

Discovering Routes Dynamically

Hardcoding routes is fragile. For data-driven sites, fetch your route list from an API or CMS before generating.

// ssg-dynamic-routes.js
const fs = require('fs');
const path = require('path');
const { renderPage, getBrowser } = require('./renderer');

const CLIENT_URL = 'http://localhost:3000';
const OUTPUT_DIR = path.join(__dirname, 'dist');
const API_URL = 'https://api.example.com/posts';

async function getRoutes() {
  const res = await fetch(API_URL);
  const posts = await res.json();
  const postRoutes = posts.map(p => `/blog/${p.slug}`);
  return ['/', '/about', '/blog', ...postRoutes];
}

async function generateSite() {
  if (!fs.existsSync(OUTPUT_DIR)) {
    fs.mkdirSync(OUTPUT_DIR, { recursive: true });
  }

  const routes = await getRoutes();
  console.log(`Found ${routes.length} routes to generate`);

  for (const route of routes) {
    const html = await renderPage(`${CLIENT_URL}${route}`, {
      waitForSelector: '#app',
    });

    const dir = route === '/' ? OUTPUT_DIR : path.join(OUTPUT_DIR, route);
    fs.mkdirSync(dir, { recursive: true });
    fs.writeFileSync(path.join(dir, 'index.html'), html);
    console.log(`Generated: ${route}`);
  }

  const browser = await getBrowser();
  await browser.close();
}

generateSite();

Implementing Incremental Static Regeneration (ISR)

ISR bridges the gap between SSG and SSR. Pages are generated on the first request and cached as static files. After a configurable staleness period, the next request triggers a background regeneration while still serving the stale version immediately. This gives you SSG-level performance with SSR-level freshness.

Implementing ISR requires a server that can serve cached files, detect staleness, and trigger regeneration. Here is a complete implementation:

// isr-server.js
const express = require('express');
const fs = require('fs');
const path = require('path');
const { renderPage } = require('./renderer');

const app = express();
const CLIENT_URL = 'http://localhost:3000';
const CACHE_DIR = path.join(__dirname, 'isr-cache');
const REVALIDATE_SECONDS = 60; // Regenerate after 60 seconds

if (!fs.existsSync(CACHE_DIR)) {
  fs.mkdirSync(CACHE_DIR, { recursive: true });
}

function getCachePath(route) {
  const safe = route.replace(/\//g, '_') || 'root';
  return {
    htmlPath: path.join(CACHE_DIR, `${safe}.html`),
    metaPath: path.join(CACHE_DIR, `${safe}.meta.json`),
  };
}

function readCache(route) {
  const { htmlPath, metaPath } = getCachePath(route);
  if (!fs.existsSync(htmlPath) || !fs.existsSync(metaPath)) {
    return null;
  }
  const html = fs.readFileSync(htmlPath, 'utf-8');
  const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
  return { html, meta };
}

function writeCache(route, html) {
  const { htmlPath, metaPath } = getCachePath(route);
  fs.writeFileSync(htmlPath, html);
  fs.writeFileSync(metaPath, JSON.stringify({
    generatedAt: Date.now(),
  }));
}

function isStale(meta) {
  const ageSeconds = (Date.now() - meta.generatedAt) / 1000;
  return ageSeconds > REVALIDATE_SECONDS;
}

// Track in-flight regenerations to avoid duplicates
const regenerating = new Set();

async function regenerate(route) {
  if (regenerating.has(route)) return;
  regenerating.add(route);

  try {
    console.log(`Regenerating ${route}...`);
    const html = await renderPage(`${CLIENT_URL}${route}`, {
      waitForSelector: '#app',
    });
    writeCache(route, html);
    console.log(`Regeneration complete for ${route}`);
  } catch (err) {
    console.error(`Regeneration failed for ${route}:`, err.message);
  } finally {
    regenerating.delete(route);
  }
}

app.get('*', async (req, res) => {
  const route = req.originalUrl;
  const cached = readCache(route);

  if (cached) {
    // Serve cached version immediately
    res.set('Content-Type', 'text/html');
    res.send(cached.html);

    // If stale, trigger background regeneration (non-blocking)
    if (isStale(cached.meta)) {
      regenerate(route).catch(console.error);
    }
    return;
  }

  // No cache exists — render on demand (first request)
  try {
    const html = await renderPage(`${CLIENT_URL}${route}`, {
      waitForSelector: '#app',
    });
    writeCache(route, html);
    res.set('Content-Type', 'text/html');
    res.send(html);
  } catch (err) {
    console.error('ISR render failed:', err.message);
    res.status(500).send('Rendering error');
  }
});

app.listen(8080, () => {
  console.log('ISR server running on port 8080');
});

How the ISR Flow Works

Best Practices

Reuse Browser Instances

Launching a browser is expensive. Always maintain a single browser instance and create new pages (browser.newPage()) for each render. Close pages after use but keep the browser alive for the lifetime of the process.

Set Reasonable Timeouts

Third-party scripts and slow APIs can hang indefinitely. Always set a timeout on page.goto() and use waitForSelector with a timeout to fail fast rather than hang.

await page.goto(url, {
  waitUntil: 'networkidle0',
  timeout: 15000,
});

await page.waitForSelector('#app', { timeout: 10000 });

Block Unnecessary Resources

For SEO-focused rendering, you often do not need images, fonts, or analytics scripts. Blocking them dramatically speeds up rendering.

await page.setRequestInterception(true);

page.on('request', (req) => {
  const type = req.resourceType();
  if (['image', 'font', 'media'].includes(type)) {
    req.abort();
  } else {
    req.continue();
  }
});

Handle Memory Leaks

Puppeteer pages that are not closed properly will accumulate and cause memory issues. Always use try/finally to ensure pages are closed even when errors occur. Additionally, consider restarting the browser periodically in long-running processes.

let renderCount = 0;

async function renderPage(url) {
  renderCount++;
  if (renderCount > 100) {
    const browser = await getBrowser();
    await browser.close();
    browserInstance = null;
    renderCount = 0;
  }
  // ... rest of rendering logic
}

Detect and Serve Bots Only

For SSR scenarios where you want to keep client-side rendering for regular users but serve rendered HTML to crawlers, inspect the User-Agent header.

const BOT_AGENTS = /googlebot|bingbot|slurp|duckduckbot|baiduspider|yandexbot/i;

app.get('*', (req, res, next) => {
  const isBot = BOT_AGENTS.test(req.headers['user-agent'] || '');
  if (isBot) {
    return ssrHandler(req, res);
  }
  return next(); // Fall through to static file serving
});

Use a Render Queue for SSG

When generating hundreds or thousands of pages, rendering them sequentially can be slow. Use a concurrency-limited queue to parallelize renders without overwhelming the browser.

async function renderBatch(routes, concurrency = 4) {
  const queue = [...routes];

  async function worker() {
    while (queue.length > 0) {
      const route = queue.shift();
      const html = await renderPage(`${CLIENT_URL}${route}`);
      const dir = path.join(OUTPUT_DIR, route);
      fs.mkdirSync(dir, { recursive: true });
      fs.writeFileSync(path.join(dir, 'index.html'), html);
      console.log(`Done: ${route}`);
    }
  }

  await Promise.all(Array.from({ length: concurrency }, () => worker()));
}

Graceful Shutdown

When your server receives a termination signal, close the browser cleanly to avoid orphaned Chrome processes.

process.on('SIGTERM', async () => {
  console.log('Shutting down...');
  const browser = await getBrowser();
  await browser.close();
  process.exit(0);
});

Conclusion

Puppeteer provides a flexible, framework-agnostic approach to server-side rendering that works with any frontend stack. SSR gives you fresh content on every request, SSG delivers maximum performance by pre-rendering at build time, and ISR combines the best of both by serving cached pages while regenerating them in the background. By reusing browser instances, blocking unnecessary resources, handling timeouts gracefully, and implementing proper caching strategies, you can build a robust rendering pipeline that improves SEO, time-to-first-byte, and user experience without rewriting your existing client-side application. Start with the strategy that matches your content's update frequency, and remember that you can always combine approaches — for example, using SSG for marketing pages and ISR for blog posts — within the same infrastructure.

— Ad —

Google AdSense will appear here after approval

← Back to all articles