Search pages, headings and code across the docs, examples and tutorials.

to navigateto open
HTTP API

Middleware

Code that runs before the handler on every matching request.

A middleware is a function that runs before the route handler, once per request. It receives the request event, the same object useEvent returns. With it, a middleware can authenticate, set headers, share data with the handler, or answer the request instead of the handler.

middleware/global/request-id.ts
import { defineMiddleware } from 'ohnejs';

export default defineMiddleware((event) => {
  event.response.headers.set('x-request-id', crypto.randomUUID());
});

The rules are simple:

  • Return nothing, and the request continues to the next middleware, then the handler.
  • Return any value, and it becomes the response, serialized exactly like a handler's return. The handler never runs.

Throw or return an HTTPError, and the response uses its status.

Global middleware

Middleware lives in each layer's dirs.middleware directory, middleware/ by default. Where a file sits decides when it runs:

  • Under global/, it runs on every request a route answers, before any opt-in middleware. A path no route matches answers 404 or 405 before any middleware.
  • Anywhere else, it is opt-in, so it runs only on the routes that select it.

Globals from every layer run together in name order, so a numeric prefix orders them: global/10-request-id.ts runs before global/20-locale.ts. A digit sorts before any letter, so prefixed globals run before unprefixed ones.

A global that should only act on part of the app limits itself with matchPath, which tests the current request's path against one or more patterns:

middleware/global/session.ts
import { defineMiddleware, matchPath, unauthorized } from 'ohnejs';

export default defineMiddleware(async (event) => {
  if (!matchPath('/admin/**')) return;
  const session = await readSession(event.request);
  if (!session) return unauthorized();
  event.context.auth = session;
});
  • A pattern with a [param] matches like a route: /authors/[id].
  • Anything else is a glob: * matches one segment, and ** any depth.
  • A glob also matches the path with one trailing / dropped, as a route does, so /admin covers /admin/.
  • matchPath sees the path the router matches, so /%61dmin/x is /admin/x.
  • matchesPath(path, ...patterns) is the same test, for when you have a path instead of a request.

The example stores the session on event.context, the per-request object that a middleware fills and a handler reads. The event shows how to type it by augmenting EventContext.

Route middleware

Every middleware outside global/ has a name and does nothing until a route selects it through defineHandler's middleware option:

middleware/audit-log.ts
import { defineMiddleware } from 'ohnejs';

export default defineMiddleware((event) => {
  console.info(`${event.request.method} ${event.url.pathname} from ${event.ip}`);
});
api/search.get.ts
import { defineHandler } from 'ohnejs';

export default defineHandler(() => search(), { middleware: ['audit-log'] });

The route runs every global middleware, then audit-log, then the handler.

  • An array lists the named middleware to run, in that order. Duplicates and unknown names are dropped.
  • The globals always run. The option adds on top of them and cannot disable them.

The option also takes a function. It receives every named middleware in the app and returns the ones to run:

api/reports.get.ts
import { defineHandler } from 'ohnejs';

export default defineHandler(() => report(), {
  middleware: (available) => available.filter((name) => name !== 'audit-log'),
});

The selection is computed once per route, not on every request.

Names and layers

The file's path under middleware/ names it, in kebab-case:

  • audit-log.ts is audit-log.
  • shop/audit.ts is shop-audit.
  • global/auth.ts is global-auth, since the global/ prefix is part of the name.

A _-prefixed file or directory is a helper and is ignored. It is an error when two files in one layer resolve to the same name.

Layers merge by name: when two layers define the same name, the closer layer's file wins. So an app replaces a layer's middleware by shadowing it with a file at the same path. The tier must match, so a name cannot be global in one layer and opt-in in another.

Per-request control

The middleware:resolve hook filters or reorders the resolved list just before it runs. The list has the globals first, then the route's selection. Use the hook only for dynamic, per-request decisions. Normal selection belongs on the route.

boot/middleware.ts
import { hook, matchesPath } from 'ohnejs';

hook('middleware:resolve', (names, event) =>
  matchesPath(event.url.pathname, '/public/**')
    ? names.filter((name) => name !== 'global-session')
    : names,
);

CORS

The ohnejs/base layer ships middleware/global/cors.ts. The dashboard's origin may make requests with credentials, and every other origin gets no CORS headers. The dashboard's origin is the first of these that is set:

  1. The DASHBOARD_URL env var.
  2. dashboard.origin in config.
  3. dashboard.host on dashboard.port, where an unset or wildcard host means localhost.

Without the ohnejs/base layer, every response carries Access-Control-Allow-Origin: * without credentials, so cookies stay safe.

To pick the origins yourself, shadow that file and keep the dashboard's origin in the list, here https://admin.example.com:

middleware/global/cors.ts
import { cors } from 'ohnejs';

export default cors({
  origin: ['https://app.example.com', 'https://admin.example.com'],
  credentials: true,
});

Keeping the dashboard's origin and credentials: true is what lets the dashboard write with its session cookie. Any other origin you list with credentials may write with the cookie too.

Only a file that resolves to the same name, global-cors, shadows the shipped policy. Any other name, such as global/10-cors.ts, adds a second global middleware, and both policies run.

Adding cors() replaces the open default. Its options:

  • origin - a single origin, a list, or '*'. Origins match exactly: scheme, host, and port. There is no reflection mode, so an origin you did not list gets no CORS headers, and the browser blocks the read.
  • credentials - send Access-Control-Allow-Credentials, so cookies and HTTP auth work across origins. Combining it with origin: '*' throws, since the browser does not allow credentials with a wildcard.
  • methods - preflight allow-list. Defaults to GET, HEAD, PUT, PATCH, POST, DELETE.
  • allowHeaders - request headers to allow. When omitted, the preflight allows the headers the browser asked for. A list must include the ones the dashboard sends: content-type, and upload-offset for resumable uploads.
  • exposeHeaders - response headers that scripts may read, in addition to the safelisted ones.
  • maxAge - seconds the browser may cache a preflight, so later requests to the same URL skip it. Defaults to 7200, two hours. 0 turns the cache off.

It answers a preflight OPTIONS itself, with 204 and the allow headers, so you need no .options route.

An auth middleware that rejects anonymous requests must sort after cors. A preflight carries no credentials, and rejecting it blocks the real request that follows it. Globals from every layer share one name order: global/session.ts above runs after cors, while global/auth.ts and global/20-auth.ts run before it.

A global that reads the session must sort after cors too. The session check reads the CORS headers to trust the dashboard's origin, and without them the dashboard's writes get a 403.

CORS controls what a browser will read. With credentials: true it also names the cross-origin pages that may send the session cookie on a write. It never replaces authorization. Authorization is handled separately, and deployment covers the production setup.