SSR vs CSR: Dynamic Rendering and Bot-Specific HTML with Puppeteer
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:
- Core differences between SSR and CSR,
- Search engine bot behavior and indexing impact,
- The "SSR for bots, CSR for users" approach,
- The dynamic rendering concept,
- 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.