Setting Up Dynamic Rendering for JavaScript Sites (Rendertron/Puppeteer)
Dynamic Rendering is an approach where users get the SPA, but search bots get pre-rendered HTML. Google officially recognizes this as an acceptable workaround when SSR isn't available.
When to Use Dynamic Rendering
- SPA on React/Vue/Angular without SSR
- Can't rewrite application for SSR
- Temporary solution before SSR implementation
- Specific site sections with indexing problems
Don't use: if SSR/SSG is available — Dynamic Rendering is a second-rate solution with overhead.
Architecture
Request → nginx → User-Agent check
↓
Bot? → Prerender Service (Puppeteer)
↓
HTML snapshot → Bot
↓
Human? → SPA bundle → Browser
Rendertron: self-hosted prerender
git clone https://github.com/GoogleChrome/rendertron
cd rendertron
npm install
npm run build
# Docker
docker build -t rendertron .
docker run -p 3000:3000 rendertron
# nginx: identify bots and send to Rendertron
map $http_user_agent $prerender_ua {
~*(Googlebot|Bingbot|Slurp|DuckDuckBot|Baiduspider|YandexBot|LinkedInBot|
facebookexternalhit|Twitterbot|WhatsApp|TelegramBot|Slackbot) 1;
default 0;
}
map $args $prerender_args {
~*_escaped_fragment_= 1;
default 0;
}
map $prerender_ua$prerender_args $prerender {
"11" 1;
"10" 1;
"01" 1;
default 0;
}
server {
listen 80;
server_name company.com;
location / {
if ($prerender = 1) {
rewrite .* /index.html break;
proxy_pass http://rendertron:3000/render/https://company.com$request_uri;
}
try_files $uri /index.html;
}
}
Custom prerender on Puppeteer
More control over rendering behavior:
// prerender-server.js
const express = require('express')
const puppeteer = require('puppeteer')
const app = express()
let browser = null
async function getBrowser() {
if (!browser) {
browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
]
})
}
return browser
}
app.get('/render', async (req, res) => {
const url = req.query.url
if (!url) return res.status(400).send('URL required')
try {
const browser = await getBrowser()
const page = await browser.newPage()
// Block media to speed up
await page.setRequestInterception(true)
page.on('request', req => {
const type = req.resourceType()
if (['image', 'media', 'font'].includes(type)) {
req.abort()
} else {
req.continue()
}
})
await page.goto(url, {
waitUntil: 'networkidle0',
timeout: 30000
})
// Wait for specific element (if needed)
await page.waitForSelector('[data-ssr-ready]', { timeout: 10000 })
.catch(() => {}) // not fatal if element doesn't exist
const html = await page.content()
await page.close()
// Cache result
cache.set(url, html, 3600) // 1 hour
res.set('Content-Type', 'text/html')
res.send(html)
} catch (err) {
console.error(`Render failed for ${url}:`, err)
res.status(500).send('Render failed')
}
})
app.listen(3000)
Caching Prerender Results
Rendering each page takes 1–5 seconds. Cache is mandatory:
const Redis = require('ioredis')
const redis = new Redis({ host: 'redis' })
async function cachedRender(url) {
const cacheKey = `prerender:${url}`
const cached = await redis.get(cacheKey)
if (cached) return cached
const html = await render(url)
await redis.setex(cacheKey, 3600, html)
return html
}
// Invalidate cache on deploy
async function invalidateCache(pathPattern) {
const keys = await redis.keys(`prerender:*${pathPattern}*`)
if (keys.length) await redis.del(keys)
}
Middleware in Express/Fastify
// middleware/prerender.js
const botUserAgents = [
'googlebot', 'bingbot', 'yandexbot', 'baiduspider',
'facebookexternalhit', 'twitterbot', 'linkedinbot'
]
function isBot(userAgent) {
return botUserAgents.some(bot =>
userAgent.toLowerCase().includes(bot)
)
}
module.exports = async function prerenderMiddleware(req, res, next) {
const ua = req.headers['user-agent'] || ''
if (!isBot(ua)) return next() // human — regular SPA
try {
const renderedHtml = await cachedRender(`https://${req.hostname}${req.originalUrl}`)
res.set('X-Prerendered', 'true')
res.send(renderedHtml)
} catch (err) {
// Fallback: serve regular SPA (better than 500)
next()
}
}
Prerender.io as SaaS Alternative
# Using Prerender.io without self-hosted solution
location / {
if ($prerender = 1) {
proxy_pass https://service.prerender.io/https://company.com$request_uri;
proxy_set_header X-Prerender-Token "YOUR_TOKEN";
}
try_files $uri /index.html;
}
Monitoring and Debugging
# Check that bot receives rendered HTML
curl -A "Googlebot/2.1 (+http://www.google.com/bot.html)" \
https://company.com/product/42 | grep -c "product-title"
# Should return number > 0 (elements found in HTML)
// Add marker that page fully rendered
// (for Puppeteer completion condition)
window.prerenderReady = false
// ... after data loaded:
window.prerenderReady = true
// Puppeteer waits for:
await page.waitForFunction(() => window.prerenderReady === true)
Timeline
Setting up Rendertron or custom Puppeteer prerender with nginx and Redis cache — 2–3 business days.







