Mert Tosun
← Posts
SSR vs CSR: Dynamic Rendering and Bot-Specific HTML with Puppeteer

SSR vs CSR: Dynamic Rendering and Bot-Specific HTML with Puppeteer

Mert TosunWeb Performance

One of the most important architectural choices in modern web apps is your rendering strategy: SSR, CSR, or hybrid.
This decision impacts not only user experience, but also SEO, server cost, caching design, and operational complexity.

In this guide, we will cover:

  1. Core differences between SSR and CSR,
  2. Search engine bot behavior and indexing impact,
  3. The "SSR for bots, CSR for users" approach,
  4. The dynamic rendering concept,
  5. Bot-specific full HTML generation with Puppeteer.

1) SSR vs CSR at a glance

SSR (Server-Side Rendering)

  • HTML is generated on the server.
  • The first response already contains meaningful content.
  • Bots can parse content without executing JavaScript.

CSR (Client-Side Rendering)

  • Server mostly returns shell HTML + JS bundle.
  • Content is assembled in the browser at runtime.
  • Bot quality depends on JavaScript execution reliability.

Simple flow diagram:

SSR
Client/Bot --> Request --> Server(Render) --> Full HTML --> Client/Bot

CSR
Client/Bot --> Request --> Server(Shell + JS) --> Browser JS Execute --> Rendered UI

2) Why SEO teams care

Search engines can execute JavaScript, but there are practical constraints:

  • Rendering may be queued and delayed.
  • Heavy bundles may lead to partial rendering or timeouts.
  • Critical metadata (title, description, canonical, JSON-LD) may appear too late.

For content-heavy pages (blog posts, docs, product categories), meaningful initial HTML still provides a strong indexing advantage.


3) SSR for bots, CSR for users

This pattern keeps product UX while improving crawlability:

  • Human users keep interactive CSR experience.
  • Bots receive pre-rendered HTML snapshots.

Architecture sketch:

                    +---------------------------+
Request ----------> | User-Agent Detection      |
                    +------------+--------------+
                                 |
                 +---------------+----------------+
                 |                                |
            Human User                        Search Bot
                 |                                |
             CSR App                   Render Service (Puppeteer)
        (JS bundle + APIs)             (Headless Chrome SSR)
                 |                                |
                 +---------------+----------------+
                                 |
                           HTML Response

This approach is commonly referred to as dynamic rendering.


4) What exactly is dynamic rendering?

In practical terms:

  • Detect bots via User-Agent.
  • Return a rendered snapshot for bots.
  • Return normal SPA/CSR response for users.

Note: Long-term, full SSR/SSG architecture is usually more maintainable. Dynamic rendering is often a pragmatic bridge for existing SPA stacks.


5) Building dynamic rendering with Puppeteer

5.1 Request flow

Bot Request
   |
   v
Node Middleware (isBot?)
   |
   +-- no  --> normal CSR response
   |
   +-- yes --> Puppeteer open page
               wait until network idle
               read final HTML
               cache result
               return HTML

5.2 Bot detection snippet

const BOT_UA =
  /googlebot|bingbot|yandex|duckduckbot|baiduspider|slurp|twitterbot|facebookexternalhit|linkedinbot/i;

function isBot(userAgent: string): boolean {
  return BOT_UA.test(userAgent);
}

5.3 Express + Puppeteer baseline

import express from 'express';
import puppeteer from 'puppeteer';

const app = express();
const ORIGIN = 'https://example.com';
const cache = new Map<string, { html: string; expiresAt: number }>();

function isBot(ua = '') {
  return /googlebot|bingbot|yandex|duckduckbot|twitterbot|facebookexternalhit/i.test(ua);
}

app.get('*', async (req, res, next) => {
  try {
    const ua = req.headers['user-agent'] || '';
    if (!isBot(String(ua))) return next(); // human -> normal app

    const key = req.originalUrl;
    const hit = cache.get(key);
    const now = Date.now();
    if (hit && hit.expiresAt > now) {
      res.setHeader('x-render-cache', 'HIT');
      return res.status(200).send(hit.html);
    }

    const browser = await puppeteer.launch({ headless: true });
    const page = await browser.newPage();
    await page.setUserAgent(String(ua));
    await page.goto(`${ORIGIN}${req.originalUrl}`, { waitUntil: 'networkidle2', timeout: 30000 });
    const html = await page.content();
    await browser.close();

    cache.set(key, { html, expiresAt: now + 1000 * 60 * 5 }); // 5 min cache
    res.setHeader('x-render-cache', 'MISS');
    return res.status(200).send(html);
  } catch (err) {
    return next(err);
  }
});

6) Production concerns you should not skip

  • Timeout budgets: enforce hard render timeout (e.g. 10-30s).
  • Caching is mandatory: launching headless Chrome per request is expensive.
  • Selective resource blocking: skip analytics/ads where possible during bot rendering.
  • Isolated render tier: move rendering to a dedicated service under higher load.
  • Observability: track render latency, error ratio, and cache hit ratio.

7) When is dynamic rendering a good choice?

Good fit:

  • You already run an SPA and need faster SEO gains.
  • Full migration to SSR/SSG is not immediately feasible.

Poor fit:

  • Greenfield project where SSR/SSG can be designed from day one.

Conclusion

SSR and CSR are not enemies; they are trade-off tools.
Dynamic rendering provides a practical bridge for SPA-heavy products: users keep CSR interactivity while bots receive fully rendered HTML snapshots through Puppeteer.

The long-term north star is still architecture simplification: gradually move critical pages to SSR/SSG and reduce reliance on bot-specific rendering pipelines.