Skip to content

Pre-GA Design Partner and Early Access only. Request access

Helix Docs

Middleware

Run code before and after your handlers with functions/_middleware.ts files that nest from the project root down to each route.

For developers Updated Aug 5, 2026
View as Markdown

Middleware runs before your function handlers. A file named _middleware.ts in functions/ or any subdirectory applies to every function route at and below that level, and its (ctx, next) signature wraps the handler so you can run code both before and after it. Typical uses: auth checks, request logging, and timing.

Writing middleware

Define middleware with defineMiddleware from the SDK. Code before await next() runs on the way in; code after it runs once the handler (and any deeper middleware) has finished.

// functions/_middleware.ts
// Applies to ALL function routes
import { defineMiddleware } from '@trayai/helix-sdk';

export default defineMiddleware(async (ctx, next) => {
  const start = Date.now();
  ctx.log.info(`→ ${ctx.method} ${ctx.path}`);

  await next();

  ctx.log.info(`← ${ctx.method} ${ctx.path} ${Date.now() - start}ms`);
});

The underscore prefix keeps the file out of routing: _middleware.ts never becomes an HTTP endpoint. See functions and routing for the underscore rules.

Scoping and execution order

Middleware applies at its directory level and below, and nested files run from root to leaf. For a GET /api/v1/users request:

functions/_middleware.ts           → runs first (root)
functions/api/v1/_middleware.ts    → runs second (nested)
functions/api/v1/users.get.ts      → handler runs last

Each level wraps the next. Calling next() passes control to the next middleware or the final handler; when that returns, execution continues after the next() call, unwinding back out to the root. Throwing before next() stops the chain, so the handler never runs:

// functions/api/v1/_middleware.ts
// Applies to /api/v1/* routes only
import { defineMiddleware } from '@trayai/helix-sdk';

export default defineMiddleware(async (ctx, next) => {
  const apiKey = ctx.headers.get('x-api-key');
  if (!apiKey) throw ctx.error(401, 'API key required');

  // Attach data to ctx.state for downstream handlers
  ctx.state.apiKey = apiKey;

  await next();
});

Passing data downstream with ctx.state

ctx.state is a plain object shared by every middleware and the handler for one request. Set values in middleware, read them anywhere downstream:

// functions/api/v1/users.get.ts
import { defineFunction } from '@trayai/helix-sdk';

export default defineFunction(async (ctx) => {
  // Set by functions/api/v1/_middleware.ts above
  ctx.log.info('Request authenticated', { apiKey: ctx.state.apiKey });
  return { ok: true };
});

What middleware can and cannot see

Middleware receives the same ctx as handlers, with a few practical differences.

Available:

  • Request metadata: ctx.method, ctx.path, and ctx.headers.
  • Platform services: ctx.kv(), ctx.http (including ctx.http.authed()), and ctx.log. These are the services the shipped product offers; see the ctx object for which fields are design rather than product.
  • ctx.state, shared with deeper middleware and the handler.
  • The post-handler window: code after await next() runs once the handler is done, which is where timing and cleanup belong.

Not available:

  • Validated input. Input schemas are named exports of individual function files, and validation types and guards the handler, so middleware can’t rely on ctx.input matching any schema. Read ctx.headers and raw request metadata instead.
  • Non-function traffic. Middleware applies to function routes only. Static assets and the SPA fallback are served without running it.

Loading search…

Jump to a section

tab to move · esc to close