rendermw — Zero-Dependency Dynamic Rendering Middleware for Express SPAs
Single-page applications solved frontend UX years ago.
SEO is still a mess.
Most React/Vue/Angular SPAs ship an almost empty HTML document to crawlers:
<div id="root"></div>
Humans eventually see content after hydration.
Bots often don't.
That creates problems with:
- search indexing,
- Open Graph previews,
- structured data,
- link unfurling,
- and crawl reliability.
Most existing solutions are operationally expensive:
- headless Chrome clusters,
- Puppeteer rendering,
- external prerender APIs,
- or full SSR rewrites.
I wanted something much smaller and much more deterministic.
So I built rendermw.
What is rendermw?
rendermw is a zero-dependency Express middleware that dynamically serves semantic HTML to bots while real users continue receiving the normal SPA.
No Puppeteer.
No Chromium.
No external rendering services.
No framework lock-in.
Just route-driven semantic HTML generated from your existing backend data.
The core idea
Most SPAs already know:
- what data belongs on the page,
- what metadata should exist,
- what schema should be emitted,
- and what the semantic HTML structure should look like.
You already have:
- database queries,
- APIs,
- route params,
- product/article metadata,
- pricing,
- breadcrumbs,
- and canonical URLs.
So instead of trying to server-render the entire React application, rendermw focuses on only what bots actually need:
- semantic HTML,
- metadata,
- JSON-LD,
- Open Graph tags,
- Twitter cards,
- crawlable content.
That's it.
The architecture
Incoming Request
│
▼
┌────────────────────────────────────────────┐
│ rendermw │
│ │
│ Detect bot user-agent │
│ │ │
│ ├── Real User ───────► next() │
│ │ │
│ └── Bot │
│ │ │
│ ▼ │
│ Match route pattern │
│ │ │
│ ▼ │
│ Execute render() │
│ │ │
│ ▼ │
│ Build HTML shell │
│ │ │
│ ▼ │
│ Return semantic HTML │
└────────────────────────────────────────────┘
Real users bypass everything immediately.
The first operation is bot detection.
If the request is not from a crawler:
- zero rendering work,
- zero route matching,
- zero HTML generation,
- zero cache access.
Just next().
Why not Puppeteer?
Puppeteer solves rendering by launching Chrome.
That creates multiple problems at scale:
| Problem | Impact |
|---|---|
| Chrome instances | High memory usage |
| Cold starts | Slow response times |
| Rendering overhead | CPU spikes |
| Infrastructure complexity | Hard deployments |
| Network waterfalls | Slower crawls |
| Headless instability | Random failures |
Most SEO crawlers don't actually need a fully hydrated React tree.
They need:
- metadata,
- content,
- structure,
- and schema.
So rendermw skips browser rendering entirely.
Why not SSR?
SSR frameworks are good if:
- you're starting greenfield,
- or already deeply integrated into SSR architecture.
But many teams already have:
- mature SPAs,
- large frontend codebases,
- custom Vite/Webpack builds,
- or legacy React architectures.
Migrating a production SPA to:
- Next.js,
- Nuxt,
- Remix,
- Astro,
- or full SSR
…can become a multi-month infrastructure rewrite.
rendermw works without touching the frontend architecture.
Your SPA remains unchanged.
Example
const express = require('express');
const rendermw = require('rendermw');
const app = express();
app.use(rendermw({
siteUrl: 'https://mystore.com',
routes: [
{
path: '/products/:slug',
render: async ({ slug }) => {
const product = await db.products.findBySlug(slug);
return {
title: `${product.name} — My Store`,
description: product.description,
canonical: `https://mystore.com/products/${slug}`,
ogImage: product.image,
schema: {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
},
breadcrumbs: [
{
name: 'Home',
url: 'https://mystore.com',
},
{
name: product.name,
url: `https://mystore.com/products/${slug}`,
},
],
html: `
<main>
<h1>${product.name}</h1>
<p>${product.description}</p>
</main>
`,
};
},
},
],
}));
Generated HTML
Bots receive a fully structured document:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Nike Air Max</title>
<meta name="description" content="..." />
<link rel="canonical" href="..." />
<meta property="og:title" content="Nike Air Max" />
<meta property="og:description" content="..." />
<script type="application/ld+json">
{
"@type": "Product"
}
</script>
</head>
<body>
<main>
<h1>Nike Air Max</h1>
</main>
</body>
</html>
Built-in features
Bot detection
Includes:
- Googlebot
- Bingbot
- Twitterbot
- LinkedInBot
- Discordbot
- TelegramBot
- Slackbot
- Facebook crawlers
- Ahrefsbot
- Semrushbot
- Lighthouse
- and more
Custom bots can also be added.
Route matching
Supports Express-style params:
/products/:slug
/blog/:slug
/shop/:category/:id
Params are extracted automatically.
JSON-LD support
Supports:
- Product schema
- Article schema
- Organization schema
- FAQ schema
- BreadcrumbList
- arbitrary custom schema
rendermw does not enforce schema structure.
You provide raw JSON-LD directly.
Open Graph + Twitter cards
Automatically emits:
- og:title
- og:description
- og:image
- twitter:card
- twitter:image
- canonical URLs
Relative image paths are converted into absolute URLs automatically.
Built-in cache
rendermw ships with:
- in-memory TTL caching,
- lazy expiration,
- zero dependencies.
rendermw({
cache: true,
cacheTTL: 3600,
})
Headers expose cache status:
X-Render-MW: fresh
X-Render-MW: cache
Internal design decisions
A few implementation constraints shaped the package heavily.
Zero runtime dependencies
No runtime dependencies besides Express as a peer dependency.
That means:
- no Redis requirement,
- no headless browser,
- no external services,
- no filesystem cache,
- no heavyweight abstractions.
The middleware is intentionally small.
Zero overhead for real users
The first operation is always:
if (!isBot(userAgent)) {
return next();
}
No route parsing.
No rendering.
No cache work.
Real traffic remains untouched.
Data-first rendering
The middleware does not attempt to:
- interpret React,
- parse component trees,
- execute frontend bundles,
- or emulate a browser.
It simply asks:
"What should bots see for this route?"
Then returns that HTML.
Testing
The package currently includes:
- unit tests,
- integration tests,
- cache tests,
- route matching tests,
- schema tests,
- middleware tests.
Built using:
- TypeScript
- Jest
- Supertest
Current test count:
- 100+ passing tests
Real-world applicability
This architecture works particularly well for:
- e-commerce platforms,
- marketplaces,
- CMS-backed SPAs,
- content-heavy frontend apps,
- React dashboards with public pages,
- Vite applications,
- legacy CRA apps,
- Express APIs serving frontend bundles.
Especially when:
- SEO matters,
- but SSR migration cost is too high.
What this is NOT
rendermw is not:
- a React renderer,
- an SSR framework,
- a hydration system,
- a frontend runtime.
It is specifically:
- dynamic rendering middleware,
- for bots,
- using semantic HTML,
- generated from backend data.
Example use cases
E-commerce
Generate:
- Product schema
- Offer schema
- breadcrumbs
- pricing metadata
- semantic product pages
without rendering the entire storefront server-side.
Blogs
Generate:
- Article schema
- Open Graph metadata
- author metadata
- canonical URLs
- semantic article content
for crawlers and social previews.
SaaS marketing pages
Generate:
- landing page metadata,
- feature descriptions,
- FAQ schema,
- semantic content blocks
without introducing SSR complexity into the app itself.
Why I open sourced it
Most SEO tooling around SPAs still assumes:
- SSR everywhere,
- or browser rendering everywhere.
There's a large middle ground where:
- backend data already exists,
- semantic output is deterministic,
- and full browser rendering is unnecessary.
rendermw is aimed at that layer.
Current status
Current package status:
- TypeScript support
- npm package
- strict-mode TS
- tested middleware
- MIT licensed
- open source
GitHub:
https://github.com/brighteyekid/rendermw
npm:
https://www.npmjs.com/package/rendermw
Closing thoughts
Modern SPAs solved frontend interactivity.
SEO infrastructure around SPAs is still disproportionately heavy.
In many cases you don't actually need:
- SSR,
- hydration on the server,
- or browser rendering.
You just need:
- crawlable HTML,
- metadata,
- schema,
- and deterministic semantic output.
That's the entire idea behind rendermw.








