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 app 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.

Some requests are answered before it and so never reach it. The rule is worth holding onto rather than a list: anything the listener shell or the framework's pre-analysis stage answers bypasses root middleware, and everything routed with your app reaches it. In practice that means WebSocket upgrades bound for a route.ts exporting WS, the dev live-reload SSE stream at /__webjs/events, and the framework's own /__webjs/* runtime assets and health probes, which your app needs in order to boot at all. In development only, static files under /public/* (plus the /sw.js and /offline.html root remaps and /favicon.ico) are answered there too, so a stylesheet is never queued behind the dev server's startup analysis.

One case is worth calling out on its own, because the rule gives the right answer and intuition does not: webjs.redirects and webjs.trailingSlash are configured by you, but they are resolved by the framework before your middleware runs. So a request that a redirect rule answers with a 308 never reaches root middleware, and a logging or auth middleware will not see it. If you need middleware to observe those requests, do the redirect in the middleware rather than in config.

Two things that look like exceptions are not. In production those /public/* files go through root middleware normally, so a middleware that protects an asset still protects it where it counts. And server actions are routed with your app, not answered early, so root middleware DOES run for an action call: that is what lets you gate actions with auth or rate limiting.

my-app/
  middleware.ts          # root middleware: runs on every app 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 app 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 and WEBJS_NO_TRUST_PROXY=1 is not set (that env var outranks the option). See Rate limiting for the threat model.