https://github.com/brighteyekid/rendermw
Zero-dependency dynamic rendering middleware for Express. No Puppeteer. No external services. No cost. Bots get semantic HTML. Users get your SPA.
https://github.com/brighteyekid/rendermw
angular bots crawler dynamic-rendering express expressjs indexing middleware nodejs open-graph prerender react seo spa typescript vue
Last synced: about 7 hours ago
JSON representation
Zero-dependency dynamic rendering middleware for Express. No Puppeteer. No external services. No cost. Bots get semantic HTML. Users get your SPA.
- Host: GitHub
- URL: https://github.com/brighteyekid/rendermw
- Owner: brighteyekid
- License: mit
- Created: 2026-05-28T05:57:02.000Z (28 days ago)
- Default Branch: main
- Last Pushed: 2026-05-28T08:50:45.000Z (28 days ago)
- Last Synced: 2026-05-28T10:12:33.806Z (28 days ago)
- Topics: angular, bots, crawler, dynamic-rendering, express, expressjs, indexing, middleware, nodejs, open-graph, prerender, react, seo, spa, typescript, vue
- Language: TypeScript
- Homepage: https://rendermw.vercel.app
- Size: 539 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
- Code of conduct: CODE_OF_CONDUCT.md
- Security: SECURITY.md
Awesome Lists containing this project
README


### *Bots get semantic HTML. Users get your SPA. You get rankings.*
**No Puppeteer · No paid services · No architecture rewrites · No overhead**
```bash
npm install rendermw
```
[Documentation](#the-problem) · [Quick Start](#quick-start) · [API Reference](#api-reference) · [Code of Conduct](./CODE_OF_CONDUCT.md) · [Security](./SECURITY.md)
---
## The problem
When Googlebot crawls your React, Vue, or Angular app, it sees this:
```html
```
Your products, articles, and pages are **completely invisible** to every search engine and social crawler.
Zero indexing. Zero rich results. Zero rankings.
**Every existing fix has a painful cost:**
| Solution | The catch |
|:---|:---|
| **Puppeteer / Rendertron** | Spawns a Chrome instance per request. Slow, expensive, crashes under load |
| **Prerender.io** | $99–$449/month. All bot traffic routes through a third-party service |
| **Migrate to Next.js / Nuxt** | Rewrite your entire frontend. Weeks of work. Massive risk |
**rendermw solves it in an afternoon.** Describe each route's data as a plain async function. rendermw builds the complete HTML document — title, description, canonical, Open Graph, Twitter Card, JSON-LD schema, BreadcrumbList — and serves it to bots. Real users pass through untouched.
---
## How it works

---
## Installation
```bash
npm install rendermw
```
> **Peer dependency:** Express ≥ 4.0.0
> ```bash
> npm install express
> ```
TypeScript types are included — no `@types/rendermw` needed.
---
## Quick start
```js
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.imageUrl,
ogType: 'product',
schema: {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: 'USD',
availability: 'https://schema.org/InStock',
},
},
breadcrumbs: [
{ name: 'Home', url: 'https://mystore.com' },
{ name: 'Products', url: 'https://mystore.com/products' },
{ name: product.name, url: `https://mystore.com/products/${slug}` },
],
html: `
${product.name}
${product.description}
$${product.price}
`,
};
},
},
],
}));
// SPA fallback — real users always land here
app.get('*', (_req, res) => res.sendFile('index.html', { root: './dist' }));
app.listen(3000);
```
---
## TypeScript
```ts
import express from 'express';
import rendermw from 'rendermw';
import type { RenderPayload, RenderOptions } from 'rendermw';
const app = express();
const options: RenderOptions = {
siteUrl: 'https://mystore.com',
routes: [
{
path: '/products/:slug',
render: async ({ slug }): Promise => ({
title: `${slug} — My Store`,
description: 'Premium products.',
canonical: `https://mystore.com/products/${slug}`,
html: `
${slug}
`,
}),
},
],
};
app.use(rendermw(options));
app.get('*', (_req, res) => res.sendFile('index.html', { root: './dist' }));
app.listen(3000);
```
---
## API Reference
### `rendermw(options)` → `RequestHandler`
#### `RenderOptions`
| Option | Type | Default | Description |
|:---|:---|:---:|:---|
| `siteUrl` | `string` | **required** | Base URL e.g. `"https://example.com"`. Resolves relative OG image paths. |
| `routes` | `RenderRoute[]` | **required** | Routes rendermw intercepts for bots. |
| `cache` | `boolean` | `true` | Enable in-memory HTML cache. |
| `cacheTTL` | `number` | `86400` | Cache TTL in seconds. Default: 24 hours. |
| `bots` | `string[]` | `[]` | Extra bot UA substrings beyond the built-in list. |
| `debug` | `boolean` | `false` | Log every bot hit to console. |
---
#### `RenderRoute`
| Field | Type | Description |
|:---|:---|:---|
| `path` | `string` | Express-style pattern e.g. `"/products/:slug"` |
| `render` | `(params, query) => Promise` | Called when a bot matches this route. |
`render()` receives:
- `params` — `Record` — URL path params e.g. `{ slug: "nike-air-max" }`
- `query` — `Record` — query string e.g. `{ page: "2", sort: "price" }`
---
#### `RenderPayload`
| Field | Type | Req | Description |
|:---|:---|:---:|:---|
| `title` | `string` | ✅ | `` · `og:title` · `twitter:title` |
| `description` | `string` | ✅ | `` · OG · Twitter |
| `canonical` | `string` | ✅ | `` · `og:url` |
| `html` | `string` | ✅ | Semantic body HTML served to bots |
| `ogImage` | `string` | — | OG / Twitter image. Relative paths resolved against `siteUrl` |
| `ogType` | `string` | — | Defaults to `"website"`. Use `"article"` or `"product"` |
| `schema` | `object \| object[]` | — | Raw JSON-LD. Arrays emit one `` tag per item |
| `breadcrumbs` | `Breadcrumb[]` | — | Auto-converted to `BreadcrumbList` JSON-LD |
| `lang` | `string` | — | `<html lang="">`. Defaults to `"en"` |
---
### HTML output
Every bot response is a complete, valid HTML document:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nike Air Max 270 — My Store</title>
<meta name="description" content="Experience the biggest Air unit yet.">
<link rel="canonical" href="https://mystore.com/products/nike-air-max">
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1">
<!-- Open Graph -->
<meta property="og:title" content="Nike Air Max 270 — My Store">
<meta property="og:description" content="Experience the biggest Air unit yet.">
<meta property="og:url" content="https://mystore.com/products/nike-air-max">
<meta property="og:type" content="product">
<meta property="og:image" content="https://mystore.com/images/nike-air-max.jpg">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Nike Air Max 270 — My Store">
<meta name="twitter:description" content="Experience the biggest Air unit yet.">
<meta name="twitter:image" content="https://mystore.com/images/nike-air-max.jpg">
<!-- JSON-LD: Product -->
<script type="application/ld+json">
{ "@context": "https://schema.org", "@type": "Product", "name": "Nike Air Max 270", ... }
{ "@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [ ... ] }
Nike Air Max 270
Experience the biggest Air unit yet.