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, andctx.headers. - Platform services:
ctx.kv(),ctx.http(includingctx.http.authed()), andctx.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.inputmatching any schema. Readctx.headersand raw request metadata instead. - Non-function traffic. Middleware applies to function routes only. Static assets and the SPA fallback are served without running it.