v0.1.0 — open source

SEO for SPAs.
Without the tradeoffs.

Zero-dependency Express middleware. Bots get complete semantic HTML. Real users pass through untouched. No Puppeteer. No paid services.

npm install rendermw
0 runtime dependencies
108 tests passing
701ns overhead per real user
18kb packed size

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

01
Bot detected
Every request passes through isBot(userAgent) — a fast, case-insensitive substring match against 30+ built-in crawlers. Real users return in under 1 microsecond.
detect.ts
02
Route matched
The request path is matched against your route list using Express-style :param segments. Params are extracted and decoded. No match means next() — the SPA takes over.
index.ts
03
Cache checked
The in-memory TTL cache is consulted first. Cache key is req.path + JSON.stringify(req.query). A hit serves the cached HTML in ~38ns. No Redis. No filesystem.
cache.ts
04
Your render() called
On cache miss, your async render(params, query) function is called. Query your database. Return a plain object with title, description, schema, breadcrumbs, and HTML. That's it.
your code
05
HTML shell built
rendermw assembles a complete HTML document — canonical, Open Graph, Twitter Card, JSON-LD schema, BreadcrumbList — from your payload. No templates to maintain.
shell.ts
server.js
// 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

701ns isBot() — non-bot entire cost for a real user
37ns isBot() — Googlebot 26.74M ops/sec
38ns cache hit 26.53M ops/sec
7ns cache miss 153.95M ops/sec
337ns route match, 1 param 2.97M ops/sec
5.9µs full HTML shell schema + breadcrumbs + OG