Skip to content

WHATWG Request/Response Adapter

Use standard WHATWG Request and Response objects with Express instead of Node's http.IncomingMessage and http.ServerResponse. Write framework-agnostic handlers that work across Express, Cloudflare Workers, Deno, and any platform supporting the Fetch API.

When to Use

  • Writing handlers that should be portable across server runtimes (Node.js, Deno, Cloudflare Workers, Bun)
  • Preferring the Fetch API's Request/Response over Express's req/res
  • Migrating from Express to a standards-based server incrementally
  • Using libraries that expect WHATWG Request objects

The Pattern

The @whatwg-node/server package bridges Express and the WHATWG Fetch API. It translates Express's req/res into standard Request/Response objects and back.

Adapt for Express

Use createServerAdapter to mount a WHATWG handler in Express:

javascript
import express from "express";
import { createServerAdapter }
  from "@whatwg-node/server";

const app = express();

const adapter = createServerAdapter(handleRequest);
app.use("/api", adapter);

app.listen(3000);

Adapt for other runtimes

The same handleRequest function works directly in platforms that natively support the Fetch API:

javascript
// Cloudflare Workers
export default {
  fetch: handleRequest,
};
javascript
// Deno
Deno.serve(handleRequest);
javascript
// Bun
export default {
  fetch: handleRequest,
};

Writing framework-agnostic handlers

Write handlers using the standard Fetch API signature — a function that receives a Request and returns a Response:

javascript
async function handleRequest(request) {
  const url = new URL(request.url);
  
  if (url.pathname === "/api/hello") {
    return new Response(
      JSON.stringify({ message: "Hello" }),
      {
        status: 200,
        headers: {
          "Content-Type": "application/json",
        },
      },
    );
  }
  
  return new Response("Not Found", { status: 404 });
}

This handler has no Express dependency — it receives a standard Request and returns a standard Response.

Key Concepts

The Fetch API is the universal interface. The WHATWG Request and Response classes are available in all modern JavaScript runtimes. Writing handlers against this interface makes them portable.

Adapters bridge the gap. Express uses Node's IncomingMessage/ ServerResponse. The adapter translates bidirectionally so existing Express middleware still works alongside WHATWG handlers.

Incremental migration. Mount WHATWG handlers on specific paths while keeping the rest of the Express app unchanged. Migrate route by route.

JSON API endpoint

Use Response.json() for cleaner JSON responses, and branch on request.method for REST semantics:

javascript
async function handleUsers(request) {
  if (request.method === "GET") {
    const users = await db.listUsers();
    return Response.json(users);
  }
  
  if (request.method === "POST") {
    const body = await request.json();
    const user = await db.createUser(body);
    return Response.json(user, { status: 201 });
  }
  
  return new Response("Method Not Allowed", {
    status: 405,
  });
}

Reading request body

The Request object provides typed body parsing methods like formData(), json(), and text():

javascript
async function handleUpload(request) {
  const formData = await request.formData();
  const file = formData.get("file");
  // process file...
  return new Response("OK");
}

Streaming response

Return a ReadableStream body to stream data without buffering the full response in memory:

javascript
function handleStream() {
  const stream = new ReadableStream({
    start(controller) {
      controller.enqueue("chunk 1\n");
      controller.enqueue("chunk 2\n");
      controller.close();
    },
  });
  
  return new Response(stream, {
    headers: {
      "Content-Type": "text/plain",
    },
  });
}

Combining with File-Based Routing

The file-based routing pattern and the WHATWG adapter complement each other: file structure defines what routes exist; Request/Response defines how handlers are written.

Rather than branching on request.method inside a single handler, export a named function per HTTP verb — get, post, put, etc. The router registers only the methods each file exports; all others return 404 automatically with no explicit handling needed.

javascript
// api/accounts/[id]/index.js
export async function get(request, { params }) {
  const account = await db.getAccount(params.id);
  return Response.json(account);
}

export async function put(request, { params }) {
  const body = await request.json();
  const updated = await db.updateAccount(params.id, body);
  return Response.json(updated);
}
// DELETE, POST, PATCH — not exported → automatic 404

The router scans each module for recognized HTTP method names and mounts only the handlers it finds:

javascript
// autoRouter.js — WHATWG-aware version
import { join } from "node:path";
import { globSync } from "glob";
import express from "express";
import { createServerAdapter } from "@whatwg-node/server";

const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"];
const defaults = { pattern: "**/*.js" };

export async function autoRouter(dir = process.cwd(), options) {
  options = { ...defaults, ...options };
  const filePaths = globSync(join(dir, options.pattern));
  const app = express();

  for (const filePath of filePaths) {
    const browserPath = toBrowserPath(filePath, dir);
    const module = await import(filePath);

    for (const method of HTTP_METHODS) {
      if (typeof module[method] === "function") {
        const handler = module[method];
        app[method](browserPath, (req, res, next) => {
          createServerAdapter(
            (request) => handler(request, { params: req.params }),
          )(req, res, next);
        });
      }
    }
  }

  return app;
}

Cloudflare Pages Functions uses the same idea with capitalized onRequestGet, onRequestPut exports — the mental model carries over even though the exact API differs:

javascript
// Cloudflare Pages Functions: functions/accounts/[id].js
export async function onRequestGet({ request, params }) {
  const account = await db.getAccount(params.id);
  return Response.json(account);
}

export async function onRequestPut({ request, params }) {
  const body = await request.json();
  const updated = await db.updateAccount(params.id, body);
  return Response.json(updated);
}

Trade-offs

ApproachProsCons
WHATWG adapterPortable, standardExtra dependency
Express req/resFamiliar ecosystemLocked to Express
Direct Node HTTPNo frameworkLow-level, verbose

Adopt when portability matters. If the server will only ever run on Express, the adapter adds indirection without clear benefit. If handlers need to run across runtimes, the standard Fetch API is the natural choice.