Skip to content

Middleware

Middleware in WebJs lets you intercept requests before they reach your pages, API routes, or server actions. Use it for authentication, logging, rate limiting, CORS, header injection, or any cross-cutting concern. WebJs supports two levels of middleware: a single root middleware and per-segment middleware scoped to subtrees of your route hierarchy.

Root Middleware

Place a middleware.ts at the root of your project (next to app/, not inside it). This middleware runs on every request before WebJs routes it to a page, API route, or server action. Any of middleware.ts, .js, .mts, or .mjs works, and .ts wins if you somehow have more than one.

my-app/
  middleware.ts          # root middleware: runs on every request
  app/
    page.ts
    api/
      hello/
        route.ts
// middleware.ts
export default async function middleware(
  req: Request,
  next: () => Promise<Response>,
): Promise<Response> {
  const started = Date.now();
  const resp = await next();
  const elapsed = Date.now() - started;
  console.log(`${req.method} ${new URL(req.url).pathname} -> ${resp.status} (${elapsed}ms)`);
  resp.headers.set('x-response-time', `${elapsed}ms`);
  return resp;
}

Per-Segment Middleware

Place a middleware.ts inside any directory under app/ to scope it to that subtree. It runs only for requests whose URL matches that segment and its children.

my-app/
  middleware.ts               # root: every request
  app/
    page.ts                   # /: root + no segment middleware
    dashboard/
      middleware.ts            # only /dashboard/* requests
      page.ts                 # / dashboard
      settings/
        page.ts               # /dashboard/settings
    api/
      auth/
        middleware.ts          # only /api/auth/* requests
        login/
          route.ts             # POST /api/auth/login
        signup/
          route.ts             # POST /api/auth/signup

Signature

Every middleware function has the same signature, whether root or per-segment:

export default async function middleware(
  req: Request,
  next: () => Promise<Response>,
): Promise<Response>
  • req: a standard Request object. Read headers, cookies, URL, method, body.
  • next(): calls the next middleware in the chain, or the final handler (page render, API route, action). Returns a Promise<Response>.
  • Return value: you must return a Response. Either pass through the one from next() (optionally modified), or return your own to short-circuit the chain.

The file must export default a function. Named exports are ignored.

Chain Order

When a request arrives, middleware executes in this order:

  1. Root middleware (middleware.ts at project root)
  2. Outermost segment middleware (app/middleware.ts if it exists)
  3. Next segment (app/dashboard/middleware.ts)
  4. Innermost segment (deepest middleware.ts on the matched route)
  5. Handler (page SSR, API route, or server action)

Each middleware calls next() to proceed. Responses bubble back up through the chain in reverse order, so outer middleware can inspect or modify the final response.

// Execution flow for GET /dashboard/settings:
//
//   root middleware
//     -> app/dashboard/middleware.ts
//       -> SSR app/dashboard/settings/page.ts
//       <- Response
//     <- Response (dashboard middleware can modify)
//   <- Response (root middleware can modify)

Short-Circuiting

Return a Response without calling next() to stop the chain early. The request never reaches downstream middleware or the route handler.

// app/api/middleware.ts: require API key for all /api/* routes
export default async function apiAuth(
  req: Request,
  next: () => Promise<Response>,
): Promise<Response> {
  const key = req.headers.get('x-api-key');
  if (key !== process.env.API_KEY) {
    return Response.json(
      { error: 'Invalid API key' },
      { status: 401 },
    );
  }
  return next();
}

Use Case: Auth Gate on /dashboard

A common pattern: require authentication for an entire subtree by placing a middleware in the segment directory.

// app/dashboard/middleware.ts
import { cookies } from '@webjsdev/server';
import { getUserByToken, SESSION_COOKIE } from '#lib/session.server.ts';

export default async function requireAuth(
  req: Request,
  next: () => Promise<Response>,
): Promise<Response> {
  const user = await getUserByToken(cookies().get(SESSION_COOKIE));
  if (!user) {
    const to = encodeURIComponent(new URL(req.url).pathname);
    return new Response(null, {
      status: 302,
      headers: { location: `/login?then=${to}` },
    });
  }
  return next();
}

Every page and API route under app/dashboard/ is now protected. Unauthenticated users are redirected to /login with a then query param so they can be sent back after signing in.

Use Case: Logging and Timing

// middleware.ts (root)
export default async function logger(
  req: Request,
  next: () => Promise<Response>,
): Promise<Response> {
  const url = new URL(req.url);
  const start = Date.now();
  const resp = await next();
  const ms = Date.now() - start;
  console.log(`${req.method} ${url.pathname} ${resp.status} ${ms}ms`);
  return resp;
}

Use Case: CORS Headers

// app/api/middleware.ts: add CORS to all /api/* routes
export default async function cors(
  req: Request,
  next: () => Promise<Response>,
): Promise<Response> {
  // Preflight
  if (req.method === 'OPTIONS') {
    return new Response(null, {
      status: 204,
      headers: {
        'access-control-allow-origin': '*',
        'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS',
        'access-control-allow-headers': 'content-type, authorization',
        'access-control-max-age': '86400',
      },
    });
  }

  const resp = await next();
  resp.headers.set('access-control-allow-origin', '*');
  return resp;
}

WebJs also ships a ready-made cors() middleware (from @webjsdev/server) you can wrap around a single route.ts handler. Use middleware CORS when you need blanket coverage across all routes in a segment.

Rate Limiting

WebJs ships a built-in rate limiter as a middleware factory. Import rateLimit from @webjsdev/server:

// app/api/auth/middleware.ts
import { rateLimit } from '@webjsdev/server';

export default rateLimit({ window: '10s', max: 5 });

That single line protects every route under /api/auth/ (login, signup, password reset) with a limit of 5 requests per 10 seconds per IP address.

rateLimit() Options

rateLimit({
  window: '1m',       // time window: number (ms) or string: '30s', '1m', '1h'
  max: 60,            // max requests per window per key
  key: req => {       // custom key function (default: IP from x-forwarded-for)
    return `login:${req.headers.get('x-forwarded-for') || 'anon'}`;
  },
  message: 'Slow down' // custom 429 error message
})

The rate limiter is in-memory and uses a fixed-window algorithm. Response headers are set automatically:

  • x-ratelimit-limit: the max for this window
  • x-ratelimit-remaining: requests left in the current window
  • x-ratelimit-reset: unix timestamp when the window resets
  • retry-after: seconds until the window resets (on 429 responses only)

For multi-instance deployments, rate-limit at the edge (nginx, Cloudflare, AWS WAF) or use the key function to integrate with a shared store like Redis.

cookies() and headers() Helpers

WebJs provides request-scoped helpers via @webjsdev/server that let you read cookies and headers from anywhere in your server-side code (middleware, pages, server actions, API routes) without explicitly threading the request object:

import { cookies, headers } from '@webjsdev/server';

// In any server-side function:
const token = cookies().get('session_token');
const hasToken = cookies().has('session_token');
const allCookies = cookies().entries(); // [string, string][]

const auth = headers().get('authorization');
const userAgent = headers().get('user-agent');

These are backed by AsyncLocalStorage. The request context is established before your middleware runs, so they work everywhere in the request lifecycle. Calling them outside a request scope (e.g., at module top level) throws an error.

Note: cookies() is read-only. To set a cookie, include a Set-Cookie header on the Response you return from your middleware, API route, or server action.

Middleware and Server Actions

Root middleware runs on server action RPC calls (POST /__webjs/action/:hash/:fn) just like any other request. Per-segment middleware does not apply to server actions (they bypass the file-based route tree). For action-level guards, check auth inside the action itself, declare per-action middleware on the action, or call the action from a route.ts under a middleware-protected segment.

Middleware and API Routes

Per-segment middleware applies to API routes (route.ts) within the same subtree. If app/api/middleware.ts exists, it runs before app/api/hello/route.ts, app/api/auth/login/route.ts, and every other route under /api/.

Middleware chains nest: a request to /api/auth/login runs the root middleware, then app/api/middleware.ts, then app/api/auth/middleware.ts, then the route handler.

Tips

  • Keep middleware fast. It runs on every request in its scope. Defer heavy work to the route handler when possible.
  • Avoid mutating the request. The Web Request API is largely immutable. If you need to pass data downstream (e.g., a resolved user object), store it in a module-scoped AsyncLocalStorage or use a header.
  • One default export. Each middleware.ts must export a single default function. Multiple middleware in one file are not supported. If you need composition, chain them manually inside your export.
  • Use rateLimit() from @webjsdev/server rather than writing your own. It handles cleanup, header injection, and per-bucket IP resolution that defaults to the framework-stamped socket address (spoof-safe) and only honours X-Forwarded-For / CF-Connecting-IP / X-Real-IP when you opt in with trustProxy: true. See Rate limiting for the threat model.