Zero-dependency Express middleware. Bots get complete semantic HTML. Real users pass through untouched. No Puppeteer. No paid services.
the problem
| Approach | The cost |
|---|---|
| Puppeteer / Rendertron | Spawns a Chrome instance per request. Slow. Crashes under load. Devops nightmare. |
| Prerender.io | $99–$449 per month. All bot traffic routes through a third-party service you don't control. |
| Migrate to Next.js | Rewrite your entire frontend. Weeks of work. Massive regression risk. |
| rendermw | Describe each route's data as an async function. Deploy. Done. Your SPA is unchanged. |
how it works
isBot(userAgent) — a fast, case-insensitive substring match against 30+ built-in crawlers. Real users return in under 1 microsecond.:param segments. Params are extracted and decoded. No match means next() — the SPA takes over.req.path + JSON.stringify(req.query). A hit serves the cached HTML in ~38ns. No Redis. No filesystem.render(params, query) function is called. Query your database. Return a plain object with title, description, schema, breadcrumbs, and HTML. That's it.// Mount before your SPA fallback. That's the entire setup. const express = require('express'); const rendermw = require('rendermw'); const app = express.default(); app.use(rendermw({ siteUrl: 'https://mystore.com', routes: [{ path: '/products/:slug', render: async ({ slug }) => { const product = await db.findBySlug(slug); return { title: `${product.name} — My Store`, description: product.description, canonical: `https://mystore.com/products/${slug}`, schema: { '@type': 'Product', ... }, breadcrumbs: [{ name: 'Home', url: 'https://mystore.com' }], html: `<h1>${product.name}</h1>`, }; }, }], })); // Real users always reach this — SPA untouched app.get('*', (_req, res) => res.sendFile('index.html'));
benchmarks — node.js v22, linux x64 · 100 connections · 10s