DEV Community

DedSec
DedSec

Posted on

rendermw — Zero-Dependency Dynamic Rendering Middleware for Express SPAs

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>
Enter fullscreen mode Exit fullscreen mode

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                 │
 └────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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>
          `,
        };
      },
    },
  ],
}));
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

Headers expose cache status:

X-Render-MW: fresh
X-Render-MW: cache
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)