{"id":51111460,"url":"https://github.com/brighteyekid/rendermw","last_synced_at":"2026-06-24T18:01:27.842Z","repository":{"id":360884203,"uuid":"1252025414","full_name":"brighteyekid/rendermw","owner":"brighteyekid","description":"Zero-dependency dynamic rendering middleware for Express. No Puppeteer. No external services. No cost. Bots get semantic HTML. Users get your SPA.","archived":false,"fork":false,"pushed_at":"2026-05-28T08:50:45.000Z","size":552,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-28T10:12:33.806Z","etag":null,"topics":["angular","bots","crawler","dynamic-rendering","express","expressjs","indexing","middleware","nodejs","open-graph","prerender","react","seo","spa","typescript","vue"],"latest_commit_sha":null,"homepage":"https://rendermw.vercel.app","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/brighteyekid.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-28T05:57:02.000Z","updated_at":"2026-05-28T08:51:29.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/brighteyekid/rendermw","commit_stats":null,"previous_names":["brighteyekid/rendermw"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/brighteyekid/rendermw","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/brighteyekid%2Frendermw","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/brighteyekid%2Frendermw/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/brighteyekid%2Frendermw/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/brighteyekid%2Frendermw/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/brighteyekid","download_url":"https://codeload.github.com/brighteyekid/rendermw/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/brighteyekid%2Frendermw/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34743466,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-24T02:00:07.484Z","response_time":106,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["angular","bots","crawler","dynamic-rendering","express","expressjs","indexing","middleware","nodejs","open-graph","prerender","react","seo","spa","typescript","vue"],"created_at":"2026-06-24T18:01:26.908Z","updated_at":"2026-06-24T18:01:27.834Z","avatar_url":"https://github.com/brighteyekid.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cdiv align=\"center\"\u003e\n\n\u003cbr/\u003e\n\n\u003cimg src=\"./logo.png\" alt=\"rendermw\" width=\"120\" height=\"120\"\u003e\n\n\u003cbr/\u003e\u003cbr/\u003e\n\n\u003cimg src=\"https://img.shields.io/npm/v/rendermw?style=for-the-badge\u0026color=6366f1\u0026labelColor=0f0f0f\" alt=\"npm version\"\u003e\n\u003cimg src=\"https://img.shields.io/badge/tests-108%20passing-22c55e?style=for-the-badge\u0026labelColor=0f0f0f\" alt=\"tests\"\u003e\n\u003cimg src=\"https://img.shields.io/badge/zero-dependencies-f59e0b?style=for-the-badge\u0026labelColor=0f0f0f\" alt=\"zero deps\"\u003e\n\u003cimg src=\"https://img.shields.io/badge/license-MIT-94a3b8?style=for-the-badge\u0026labelColor=0f0f0f\" alt=\"MIT\"\u003e\n\n\u003cbr/\u003e\u003cbr/\u003e\n\n### *Bots get semantic HTML. Users get your SPA. You get rankings.*\n\n**No Puppeteer \u0026nbsp;·\u0026nbsp; No paid services \u0026nbsp;·\u0026nbsp; No architecture rewrites \u0026nbsp;·\u0026nbsp; No overhead**\n\n\u003cbr/\u003e\n\n```bash\nnpm install rendermw\n```\n\n\u003cbr/\u003e\n\n[Documentation](#the-problem) \u0026nbsp;·\u0026nbsp; [Quick Start](#quick-start) \u0026nbsp;·\u0026nbsp; [API Reference](#api-reference) \u0026nbsp;·\u0026nbsp; [Code of Conduct](./CODE_OF_CONDUCT.md) \u0026nbsp;·\u0026nbsp; [Security](./SECURITY.md)\n\n\u003cbr/\u003e\n\n\u003c/div\u003e\n\n---\n\n## The problem\n\nWhen Googlebot crawls your React, Vue, or Angular app, it sees this:\n\n```html\n\u003c!DOCTYPE html\u003e\n\u003chtml\u003e\n  \u003cbody\u003e\n    \u003cdiv id=\"root\"\u003e\u003c/div\u003e\n    \u003cscript src=\"/bundle.js\"\u003e\u003c/script\u003e\n  \u003c/body\u003e\n\u003c/html\u003e\n```\n\nYour products, articles, and pages are **completely invisible** to every search engine and social crawler.\nZero indexing. Zero rich results. Zero rankings.\n\n**Every existing fix has a painful cost:**\n\n| Solution | The catch |\n|:---|:---|\n|  **Puppeteer / Rendertron** | Spawns a Chrome instance per request. Slow, expensive, crashes under load |\n|  **Prerender.io** | $99–$449/month. All bot traffic routes through a third-party service |\n|  **Migrate to Next.js / Nuxt** | Rewrite your entire frontend. Weeks of work. Massive risk |\n\n**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.\n\n---\n\n## How it works\n\n\u003cdiv align=\"center\"\u003e\n\n![rendermw request flow diagram](./diagram.png)\n\n\u003c/div\u003e\n\n---\n\n## Installation\n\n```bash\nnpm install rendermw\n```\n\n\u003e **Peer dependency:** Express ≥ 4.0.0\n\u003e ```bash\n\u003e npm install express\n\u003e ```\n\nTypeScript types are included — no `@types/rendermw` needed.\n\n---\n\n## Quick start\n\n```js\nconst express = require('express');\nconst rendermw = require('rendermw');\n\nconst app = express();\n\napp.use(rendermw({\n  siteUrl: 'https://mystore.com',\n  routes: [\n    {\n      path: '/products/:slug',\n      render: async ({ slug }) =\u003e {\n        const product = await db.products.findBySlug(slug);\n\n        return {\n          title:       `${product.name} — My Store`,\n          description: product.description,\n          canonical:   `https://mystore.com/products/${slug}`,\n          ogImage:     product.imageUrl,\n          ogType:      'product',\n          schema: {\n            '@context': 'https://schema.org',\n            '@type':    'Product',\n            name:       product.name,\n            offers: {\n              '@type':        'Offer',\n              price:          product.price,\n              priceCurrency:  'USD',\n              availability:   'https://schema.org/InStock',\n            },\n          },\n          breadcrumbs: [\n            { name: 'Home',     url: 'https://mystore.com' },\n            { name: 'Products', url: 'https://mystore.com/products' },\n            { name: product.name, url: `https://mystore.com/products/${slug}` },\n          ],\n          html: `\n            \u003cmain\u003e\n              \u003ch1\u003e${product.name}\u003c/h1\u003e\n              \u003cp\u003e${product.description}\u003c/p\u003e\n              \u003cp\u003e\u003cstrong\u003e$${product.price}\u003c/strong\u003e\u003c/p\u003e\n            \u003c/main\u003e\n          `,\n        };\n      },\n    },\n  ],\n}));\n\n// SPA fallback — real users always land here\napp.get('*', (_req, res) =\u003e res.sendFile('index.html', { root: './dist' }));\n\napp.listen(3000);\n```\n\n---\n\n## TypeScript\n\n```ts\nimport express from 'express';\nimport rendermw from 'rendermw';\nimport type { RenderPayload, RenderOptions } from 'rendermw';\n\nconst app = express();\n\nconst options: RenderOptions = {\n  siteUrl: 'https://mystore.com',\n  routes: [\n    {\n      path: '/products/:slug',\n      render: async ({ slug }): Promise\u003cRenderPayload\u003e =\u003e ({\n        title:       `${slug} — My Store`,\n        description: 'Premium products.',\n        canonical:   `https://mystore.com/products/${slug}`,\n        html:        `\u003ch1\u003e${slug}\u003c/h1\u003e`,\n      }),\n    },\n  ],\n};\n\napp.use(rendermw(options));\napp.get('*', (_req, res) =\u003e res.sendFile('index.html', { root: './dist' }));\napp.listen(3000);\n```\n\n---\n\n## API Reference\n\n### `rendermw(options)` → `RequestHandler`\n\n#### `RenderOptions`\n\n| Option | Type | Default | Description |\n|:---|:---|:---:|:---|\n| `siteUrl` | `string` | **required** | Base URL e.g. `\"https://example.com\"`. Resolves relative OG image paths. |\n| `routes` | `RenderRoute[]` | **required** | Routes rendermw intercepts for bots. |\n| `cache` | `boolean` | `true` | Enable in-memory HTML cache. |\n| `cacheTTL` | `number` | `86400` | Cache TTL in seconds. Default: 24 hours. |\n| `bots` | `string[]` | `[]` | Extra bot UA substrings beyond the built-in list. |\n| `debug` | `boolean` | `false` | Log every bot hit to console. |\n\n---\n\n#### `RenderRoute`\n\n| Field | Type | Description |\n|:---|:---|:---|\n| `path` | `string` | Express-style pattern e.g. `\"/products/:slug\"` |\n| `render` | `(params, query) =\u003e Promise\u003cRenderPayload\u003e` | Called when a bot matches this route. |\n\n`render()` receives:\n- `params` — `Record\u003cstring, string\u003e` — URL path params e.g. `{ slug: \"nike-air-max\" }`\n- `query` — `Record\u003cstring, string\u003e` — query string e.g. `{ page: \"2\", sort: \"price\" }`\n\n---\n\n#### `RenderPayload`\n\n| Field | Type | Req | Description |\n|:---|:---|:---:|:---|\n| `title` | `string` | ✅ | `\u003ctitle\u003e` · `og:title` · `twitter:title` |\n| `description` | `string` | ✅ | `\u003cmeta name=\"description\"\u003e` · OG · Twitter |\n| `canonical` | `string` | ✅ | `\u003clink rel=\"canonical\"\u003e` · `og:url` |\n| `html` | `string` | ✅ | Semantic body HTML served to bots |\n| `ogImage` | `string` | — | OG / Twitter image. Relative paths resolved against `siteUrl` |\n| `ogType` | `string` | — | Defaults to `\"website\"`. Use `\"article\"` or `\"product\"` |\n| `schema` | `object \\| object[]` | — | Raw JSON-LD. Arrays emit one `\u003cscript\u003e` tag per item |\n| `breadcrumbs` | `Breadcrumb[]` | — | Auto-converted to `BreadcrumbList` JSON-LD |\n| `lang` | `string` | — | `\u003chtml lang=\"\"\u003e`. Defaults to `\"en\"` |\n\n---\n\n### HTML output\n\nEvery bot response is a complete, valid HTML document:\n\n```html\n\u003c!DOCTYPE html\u003e\n\u003chtml lang=\"en\"\u003e\n\u003chead\u003e\n  \u003cmeta charset=\"UTF-8\"\u003e\n  \u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"\u003e\n  \u003ctitle\u003eNike Air Max 270 — My Store\u003c/title\u003e\n  \u003cmeta name=\"description\" content=\"Experience the biggest Air unit yet.\"\u003e\n  \u003clink rel=\"canonical\" href=\"https://mystore.com/products/nike-air-max\"\u003e\n  \u003cmeta name=\"robots\" content=\"index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1\"\u003e\n\n  \u003c!-- Open Graph --\u003e\n  \u003cmeta property=\"og:title\"       content=\"Nike Air Max 270 — My Store\"\u003e\n  \u003cmeta property=\"og:description\" content=\"Experience the biggest Air unit yet.\"\u003e\n  \u003cmeta property=\"og:url\"         content=\"https://mystore.com/products/nike-air-max\"\u003e\n  \u003cmeta property=\"og:type\"        content=\"product\"\u003e\n  \u003cmeta property=\"og:image\"       content=\"https://mystore.com/images/nike-air-max.jpg\"\u003e\n\n  \u003c!-- Twitter Card --\u003e\n  \u003cmeta name=\"twitter:card\"        content=\"summary_large_image\"\u003e\n  \u003cmeta name=\"twitter:title\"       content=\"Nike Air Max 270 — My Store\"\u003e\n  \u003cmeta name=\"twitter:description\" content=\"Experience the biggest Air unit yet.\"\u003e\n  \u003cmeta name=\"twitter:image\"       content=\"https://mystore.com/images/nike-air-max.jpg\"\u003e\n\n  \u003c!-- JSON-LD: Product --\u003e\n  \u003cscript type=\"application/ld+json\"\u003e\n  { \"@context\": \"https://schema.org\", \"@type\": \"Product\", \"name\": \"Nike Air Max 270\", ... }\n  \u003c/script\u003e\n\n  \u003c!-- JSON-LD: BreadcrumbList --\u003e\n  \u003cscript type=\"application/ld+json\"\u003e\n  { \"@context\": \"https://schema.org\", \"@type\": \"BreadcrumbList\", \"itemListElement\": [ ... ] }\n  \u003c/script\u003e\n\u003c/head\u003e\n\u003cbody\u003e\n  \u003cmain\u003e\n    \u003ch1\u003eNike Air Max 270\u003c/h1\u003e\n    \u003cp\u003eExperience the biggest Air unit yet.\u003c/p\u003e\n  \u003c/main\u003e\n\u003c/body\u003e\n\u003c/html\u003e\n```\n\n---\n\n## Route matching\n\nSupports Express-style `:param` segments. Segment count must match exactly.\n\n```\nPattern                  Path                          Params extracted\n─────────────────────────────────────────────────────────────────────────\n/                        /                             {}\n/about                   /about                        {}\n/products/:slug          /products/nike-air-max        { slug: \"nike-air-max\" }\n/blog/:slug              /blog/hello-world             { slug: \"hello-world\" }\n/shop/:cat/:id           /shop/shoes/12345             { cat: \"shoes\", id: \"12345\" }\n/products/:slug          /blog/hello-world             ✗  no match → next()\n/products/:slug          /products/shoes/red           ✗  no match → next()\n```\n\nURL-encoded characters are decoded automatically via `decodeURIComponent`.\n\n---\n\n## Schema markup\n\n### Product with rich results\n\n```js\nschema: {\n  '@context': 'https://schema.org',\n  '@type':    'Product',\n  name:        product.name,\n  description: product.description,\n  image:      `https://mystore.com${product.imageUrl}`,\n  offers: {\n    '@type':        'Offer',\n    price:          product.price,\n    priceCurrency:  'USD',\n    availability:   'https://schema.org/InStock',\n    url:           `https://mystore.com/products/${product.slug}`,\n  },\n  aggregateRating: {\n    '@type':      'AggregateRating',\n    ratingValue:   product.rating,\n    reviewCount:   product.reviewCount,\n  },\n}\n```\n\n### Article / Blog post\n\n```js\nschema: {\n  '@context':      'https://schema.org',\n  '@type':         'Article',\n  headline:         post.title,\n  description:      post.excerpt,\n  author:         { '@type': 'Person', name: post.author },\n  datePublished:    post.publishedAt.toISOString(),\n  dateModified:     post.updatedAt.toISOString(),\n  publisher: {\n    '@type': 'Organization',\n    name:    'My Store',\n    logo:  { '@type': 'ImageObject', url: 'https://mystore.com/logo.png' },\n  },\n}\n```\n\n### Multiple schemas on one page\n\n```js\n// Each item in the array gets its own \u003cscript type=\"application/ld+json\"\u003e tag\nschema: [\n  { '@type': 'Product', name: product.name, ... },\n  { '@type': 'FAQPage', mainEntity: [ ... ] },\n]\n```\n\n---\n\n## Breadcrumbs\n\nPass a `breadcrumbs` array — rendermw auto-generates the `BreadcrumbList` JSON-LD. No extra code needed.\n\n```js\nbreadcrumbs: [\n  { name: 'Home',           url: 'https://mystore.com' },\n  { name: 'Products',       url: 'https://mystore.com/products' },\n  { name: 'Sneakers',       url: 'https://mystore.com/products/sneakers' },\n  { name: 'Nike Air Max',   url: 'https://mystore.com/products/nike-air-max' },\n],\n```\n\nProduces:\n\n```json\n{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"BreadcrumbList\",\n  \"itemListElement\": [\n    { \"@type\": \"ListItem\", \"position\": 1, \"name\": \"Home\",         \"item\": \"https://mystore.com\" },\n    { \"@type\": \"ListItem\", \"position\": 2, \"name\": \"Products\",     \"item\": \"https://mystore.com/products\" },\n    { \"@type\": \"ListItem\", \"position\": 3, \"name\": \"Sneakers\",     \"item\": \"https://mystore.com/products/sneakers\" },\n    { \"@type\": \"ListItem\", \"position\": 4, \"name\": \"Nike Air Max\", \"item\": \"https://mystore.com/products/nike-air-max\" }\n  ]\n}\n```\n\nOr build it manually with `buildBreadcrumbSchema()` and merge into a schema array:\n\n```js\nimport { buildBreadcrumbSchema } from 'rendermw';\n\nschema: [\n  { '@type': 'Product', ... },\n  buildBreadcrumbSchema([\n    { name: 'Home',    url: 'https://mystore.com' },\n    { name: product.name, url: `https://mystore.com/products/${slug}` },\n  ]),\n]\n```\n\n---\n\n## OG image resolution\n\nPass absolute or relative paths — rendermw resolves them automatically:\n\n```js\n// ✅ Already absolute — used as-is\nogImage: 'https://cdn.mystore.com/images/shoes.jpg'\n\n// ✅ Relative with leading slash — siteUrl prepended\nogImage: '/images/shoes.jpg'\n// → https://mystore.com/images/shoes.jpg\n\n// ✅ Relative without leading slash — siteUrl + / prepended\nogImage: 'images/shoes.jpg'\n// → https://mystore.com/images/shoes.jpg\n```\n\n---\n\n## Caching\n\nrendermw ships a zero-dependency in-memory TTL cache. No Redis. No filesystem. Just a `Map`.\n\n```\nCache key = req.path + JSON.stringify(req.query)\n```\n\n- `/search?q=shoes` and `/search?q=hats` are **separate cache entries**\n- Eviction is **lazy** — expired entries are deleted on read, not by a background timer\n- Cache resets on **server restart** — perfect for rolling deploys\n\n```js\nrendermw({\n  siteUrl:  'https://mystore.com',\n  cache:    true,\n  cacheTTL: 3600,   // 1 hour\n  routes:  [ ... ],\n})\n```\n\n**Disable during development:**\n\n```js\nrendermw({ cache: false, ... })\n// render() is called fresh on every bot request\n```\n\n**Use `RenderCache` directly for programmatic control:**\n\n```js\nimport { RenderCache } from 'rendermw';\n\nconst cache = new RenderCache();\ncache.set('/products/shoes', html, 3600);\ncache.get('/products/shoes');   // string | null\ncache.delete('/products/shoes');\ncache.clear();\ncache.size();                   // number of entries\n```\n\n**Multi-instance / Redis layer:**\n\n```js\n{\n  path: '/products/:slug',\n  render: async ({ slug }) =\u003e {\n    const hit = await redis.get(`bot:product:${slug}`);\n    if (hit) return JSON.parse(hit);\n\n    const product = await db.products.findBySlug(slug);\n    const payload = buildPayload(product);\n\n    await redis.set(`bot:product:${slug}`, JSON.stringify(payload), 'EX', 86400);\n    return payload;\n  },\n}\n```\n\n---\n\n## Debug mode\n\n```js\nrendermw({ debug: true, ... })\n```\n\n```\n[rendermw] 2024-01-15T10:32:11.482Z | BOT | Googlebot/2.1 | /products/nike-air-max | CACHE MISS | route: /products/:slug\n[rendermw] 2024-01-15T10:32:12.104Z | BOT | Googlebot/2.1 | /products/nike-air-max | CACHE HIT  | route: /products/:slug\n[rendermw] 2024-01-15T10:33:01.002Z | BOT | Twitterbot/1.0 | /blog/hello-world     | CACHE MISS | route: /blog/:slug\n[rendermw] 2024-01-15T10:34:10.554Z | BOT | LinkedInBot/1.0 | /                    | NO ROUTE\n```\n\n---\n\n## Response headers\n\n| Header | Values | Description |\n|:---|:---|:---|\n| `Content-Type` | `text/html; charset=utf-8` | Always HTML |\n| `X-Render-MW` | `fresh` · `cache` | Whether this was rendered now or served from cache |\n| `X-Render-Route` | e.g. `/products/:slug` | The route pattern that matched |\n\nVerify rendermw is working without touching your logs:\n\n```bash\ncurl -sI -A \"Googlebot\" https://mystore.com/products/nike-air-max | grep -i x-render\n# X-Render-MW: fresh\n# X-Render-Route: /products/:slug\n```\n\n---\n\n## Built-in bot list\n\nAll matching is **case-insensitive substring** — partial matches work.\n\n| Category | Bots |\n|:---|:---|\n| **Google** | `Googlebot` · `Googlebot-Image` · `Googlebot-Video` · `Google-InspectionTool` · `Mediapartners-Google` · `AdsBot-Google` · `APIs-Google` · `Google Favicon` |\n| **Search engines** | `Bingbot` · `Slurp` · `DuckDuckBot` · `Baiduspider` · `YandexBot` · `Sogou` · `Exabot` |\n| **Social** | `facebookexternalhit` · `facebot` · `Twitterbot` · `LinkedInBot` · `WhatsApp` · `TelegramBot` · `Discordbot` · `Slackbot` · `Applebot` · `Pinterestbot` |\n| **SEO tools** | `Semrushbot` · `Ahrefsbot` · `Mj12bot` · `DotBot` · `Screaming Frog` |\n| **Auditing** | `GTmetrix` · `Lighthouse` |\n\n**Add your own:**\n\n```js\nrendermw({\n  bots: ['my-internal-crawler', 'monitoring-bot/2.0'],\n  ...\n})\n```\n\n---\n\n## Error handling\n\nIf your `render()` throws, rendermw:\n\n1. Logs the error to `console.error`\n2. Calls `next()` — your normal Express handler responds\n3. The bot receives your SPA shell (same as without rendermw)\n4. **The server never crashes**\n\n```\n[rendermw] ERROR rendering /products/broken-slug (route: /products/:slug): Error: Connection timeout\n    at Object.render (server.js:42:11)\n    ...\n```\n\nNo need to wrap render functions in try/catch — rendermw handles it for you.\n\n---\n\n## Production patterns\n\n### With Prisma\n\n```js\nconst { PrismaClient } = require('@prisma/client');\nconst prisma = new PrismaClient();\n\nrendermw({\n  siteUrl:  'https://mystore.com',\n  cacheTTL: 86400,\n  routes: [\n    {\n      path: '/products/:slug',\n      render: async ({ slug }) =\u003e {\n        const product = await prisma.product.findUnique({ where: { slug } });\n        if (!product) return {\n          title:       'Product Not Found',\n          description: 'This product does not exist.',\n          canonical:  `https://mystore.com/products/${slug}`,\n          html:        '\u003ch1\u003eNot Found\u003c/h1\u003e',\n        };\n\n        return {\n          title:       `${product.name} — My Store`,\n          description:  product.description,\n          canonical:   `https://mystore.com/products/${slug}`,\n          ogImage:      product.imageUrl,\n          html:        `\u003ch1\u003e${product.name}\u003c/h1\u003e\u003cp\u003e${product.description}\u003c/p\u003e`,\n        };\n      },\n    },\n  ],\n});\n```\n\n### With CDN / reverse proxy caching\n\n```js\n// Add upstream caching headers only for rendermw responses\napp.use((req, res, next) =\u003e {\n  res.on('finish', () =\u003e {\n    if (res.getHeader('X-Render-MW')) {\n      res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=86400');\n    }\n  });\n  next();\n});\n\napp.use(rendermw({ ... }));\n```\n\n### Cache invalidation on deploy\n\nThe cache is in-memory — it resets on every server restart automatically. Rolling deploys, `pm2 reload`, and container restarts all flush the cache cleanly with no manual intervention.\n\n---\n\n## Exported utilities\n\n```ts\nimport rendermw, {\n  isBot,\n  RenderCache,\n  buildJsonLd,\n  buildBreadcrumbSchema,\n  buildShell,\n} from 'rendermw';\n\nimport type {\n  RenderOptions,\n  RenderRoute,\n  RenderPayload,\n  Breadcrumb,\n} from 'rendermw';\n```\n\n| Export | Type | Description |\n|:---|:---|:---|\n| `default` | `(options) =\u003e RequestHandler` | Middleware factory |\n| `isBot` | `(ua, extraBots?) =\u003e boolean` | Bot detection |\n| `RenderCache` | `class` | TTL cache |\n| `buildJsonLd` | `(schema) =\u003e string` | Builds `\u003cscript type=\"application/ld+json\"\u003e` |\n| `buildBreadcrumbSchema` | `(breadcrumbs) =\u003e object` | `BreadcrumbList` JSON-LD object |\n| `buildShell` | `(payload, siteUrl) =\u003e string` | Full HTML document builder |\n\n---\n\n## FAQ\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cstrong\u003eIs this cloaking?\u003c/strong\u003e\u003c/summary\u003e\n\nNo. Google defines cloaking as serving different content to manipulate rankings. rendermw serves the **same data** that would be visible to a real user — pre-built as HTML rather than hydrated client-side. This is Google's officially recommended [dynamic rendering](https://developers.google.com/search/docs/crawling-indexing/javascript/dynamic-rendering) pattern.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cstrong\u003eDoes it work with Vite / CRA / custom builds?\u003c/strong\u003e\u003c/summary\u003e\n\nYes. rendermw operates at the Express middleware layer — before your SPA bundle is served. It has no opinion about your frontend build tool. React, Vue, Angular, Svelte, vanilla JS — all supported.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cstrong\u003eDoes it work with TypeScript?\u003c/strong\u003e\u003c/summary\u003e\n\nYes. rendermw is written in TypeScript and ships full `.d.ts` declarations. All types are exported.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cstrong\u003eDoes it support query strings?\u003c/strong\u003e\u003c/summary\u003e\n\nYes. Query params are passed as the second argument to `render(params, query)` and are factored into the cache key — so `/search?q=shoes` and `/search?q=hats` are stored and served independently.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cstrong\u003eWhat if a route isn't in my routes list?\u003c/strong\u003e\u003c/summary\u003e\n\nrendermw calls `next()`. The bot gets your normal Express response — usually your SPA shell. No errors, no crashes.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cstrong\u003eCan I use it with Express Router?\u003c/strong\u003e\u003c/summary\u003e\n\nYes — mount it on a router exactly like `app.use()`.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cstrong\u003eIs there a size limit on cached HTML?\u003c/strong\u003e\u003c/summary\u003e\n\nNo hard limit. The cache is a plain `Map` bounded only by process memory. For most sites — hundreds of unique bot-visited URLs, each a few KB of HTML — this is negligible.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cstrong\u003eWill it slow down real users?\u003c/strong\u003e\u003c/summary\u003e\n\nNo. The `isBot()` check is the very first thing that runs — a simple string lowercase + substring match. If it returns false, the middleware returns immediately. There is literally zero overhead for real users beyond a microsecond string check.\n\n\u003c/details\u003e\n\n---\n\n## Real world results\n\nA high-traffic e-commerce platform running a React SPA on Express had zero organic search visibility — Googlebot was indexing empty shells. Traditional SSR would have required rewriting thousands of components. Puppeteer prerendering was too slow and crashed under load.\n\nAfter integrating rendermw and describing each route's data as a plain async function, Googlebot began receiving complete HTML documents with `Product`, `BreadcrumbList`, and `Offer` schema — within 24 hours of deployment. No frontend changes. No architecture rewrite. No paid services.\n\n---\n\n## Benchmarks\n\nAll numbers are measured on real hardware with Node.js v22. Run them yourself:\n\n```bash\nnpm run build\nnode bench/bench.js\n```\n\n```\nrendermw benchmark  (500,000 iterations each)\n\n  Benchmark                                             Speed          Throughput\n  ───────────────────────────────────────────────────────────────────────────────\n  isBot() — non-bot (Chrome UA)                   701.1 ns/op       1.43M ops/sec\n  isBot() — Googlebot                              37.4 ns/op      26.74M ops/sec\n  isBot() — Twitterbot                            124.9 ns/op       8.01M ops/sec\n  isBot() — with 5 extra custom bots              722.4 ns/op       1.38M ops/sec\n  RenderCache.get() — hit                          37.7 ns/op      26.53M ops/sec\n  RenderCache.get() — miss                           6.5 ns/op     153.95M ops/sec\n  RenderCache.set() — new key                     785.1 ns/op       1.27M ops/sec\n  matchPath() — no match                          114.5 ns/op       8.73M ops/sec\n  matchPath() — match, 1 param                    336.7 ns/op       2.97M ops/sec\n  matchPath() — match, 2 params                   429.9 ns/op       2.33M ops/sec\n  buildShell() — minimal payload                  118.5 ns/op       8.44M ops/sec\n  buildShell() — full (schema + breadcrumbs + OG)   5.92 µs/op       0.17M ops/sec\n\n  Measured on Node.js v22.22.2 — linux x64\n```\n\n### What the numbers mean\n\n| Scenario | Latency | Notes |\n|:---|---:|:---|\n| **Non-bot request overhead** | ~701 ns | The entire cost for a real user. One lowercase + substring scan. |\n| **Bot detection (Googlebot)** | ~37 ns | Matches early in the list. |\n| **Cache hit** | ~38 ns | Map lookup + expiry check. |\n| **Cache miss** | ~7 ns | Map lookup only, key absent. |\n| **Route match** | ~337 ns | Split + iterate segments. |\n| **Full HTML shell (minimal)** | ~119 ns | Template string assembly, no schema. |\n| **Full HTML shell (complete)** | ~5.9 µs | Includes JSON.stringify for schema + breadcrumbs. |\n\n**Real-world latency budget for a bot, cache miss:**\n\n```\nisBot()        ~0.7 µs\nmatchPath()    ~0.4 µs\ncache.get()    ~0.04 µs\nroute.render() your DB query (e.g. 2–20 ms)\nbuildShell()   ~6 µs\ncache.set()    ~0.8 µs\n               ────────\nTotal overhead ~8 µs  +  your DB latency\n```\n\nThe middleware itself adds less than **10 microseconds** of overhead. Your database is the only variable.\n\n### HTTP benchmarks (end-to-end)\n\nReal Express server, 100 concurrent connections, 10 second run, measured with [autocannon](https://github.com/mcollina/autocannon):\n\n```\nrendermw HTTP benchmark (Express + autocannon)\nconnections=100  duration=10s  Node.js v22.22.2 — linux x64\n\n  Scenario                              Req/sec    Avg latency    p99\n  ─────────────────────────────────────────────────────────────────────\n  1. Plain Express                       12.61K       7.44 ms    17 ms\n  2. rendermw — non-bot request           8.88K      10.79 ms    17 ms\n  3. rendermw — bot (cache miss)          6.60K      14.74 ms    65 ms\n  4. rendermw — bot (cache hit)           9.75K       9.80 ms    17 ms\n```\n\n**Reading the numbers:**\n\n| Scenario | vs plain Express | Notes |\n|:---|:---:|:---|\n| Non-bot request | −30% req/sec | Cost of `isBot()` check + Express middleware chain overhead |\n| Bot, cache miss | −48% req/sec | Includes `render()` call + `buildShell()` + cache write |\n| Bot, cache hit | −23% req/sec | HTML served from `Map` — only overhead is the cache lookup |\n\nThe non-bot overhead (~30%) reflects Express middleware chain cost, not rendermw logic — `isBot()` exits in under 1µs. The cache hit scenario nearly matches plain Express throughput while serving a complete SEO HTML document.\n\nRun it yourself:\n\n```bash\nnpm install -D autocannon\nnpm run build\nnode bench/http-bench.js\n\n# Tune load\nCONNECTIONS=200 DURATION=20 node bench/http-bench.js\n```\n\n---\n\n## Contributing\n\n```bash\ngit clone https://github.com/brighteyekid/rendermw\ncd rendermw\nnpm install\n\nnpm test           # 108 tests across 5 suites\nnpm run build      # compile TypeScript to dist/\nnode bench/bench.js  # run benchmarks\n```\n\nAll source lives in `src/`. Tests in `tests/`. PRs and issues welcome.\n\n---\n\n\u003cdiv align=\"center\"\u003e\n\n**MIT License** · Made by [Chandra Bhayal](https://github.com/brighteyekid)\n\n*If rendermw helped you, consider starring the repo *\n\n\u003c/div\u003e\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbrighteyekid%2Frendermw","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fbrighteyekid%2Frendermw","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbrighteyekid%2Frendermw/lists"}