Middleware | Koze | Primitives Docs

Middleware

Compose request-time concerns — auth, migrations, custom logic — in src/middleware.ts

src/middleware.ts is where cross-cutting request-time policy composes. It's a single file that exports a default defineMiddleware({...}) object; each property is a named step with up to four lifecycle hooks. The framework runs them in declaration order around matched pages and API routes.

Middleware is not the application router. Keep endpoint-specific HTTP behavior in API Routes under src/routes/api/; use middleware for policy that should wrap many routes, such as auth, migrations, route guards, response headers, tenant lookup, or forwarding a whole protocol to a Durable Object.

Minimum file

// src/middleware.ts
import { defineMiddleware } from 'koze:middleware';

export default defineMiddleware({
  // empty — the framework runs without any custom middleware
});

A real app layers steps on top:

// src/middleware.ts
import { defineMiddleware } from 'koze:middleware';
import { autoMigrate } from '@kuratchi/kunii';
import { kyzenAuthMiddleware } from '@kuratchi/kyzen/middleware';
import { adminSchema } from './server/schemas/admin';
import { authConfig } from './server/auth-config';

export default defineMiddleware({
  // 1. Apply pending D1 migrations on the first request per worker isolate.
  migrate: autoMigrate({ DB: adminSchema }),

  // 2. Sessions, guards, OAuth, rate-limit, turnstile.
  auth: kyzenAuthMiddleware(authConfig),

  // 3. Your own steps.
  logging: {
    async request(ctx, next) {
      const start = Date.now();
      const res = await next();
      console.log(`${ctx.request.method} ${ctx.url.pathname} ${Date.now() - start}ms`);
      return res;
    },
  },
});

That's the entire integration shape. autoMigrate and kyzenAuthMiddleware are not framework features — they're ordinary middleware steps. Swap them for any third-party auth/ORM/observability tool by replacing the step.

The four hooks

Each named step can declare up to four async hooks. Every hook is optional; declare only the ones you need.

Hook When it runs Receives Returns
request Before routing (ctx, next) A Response (short-circuits) or await next()
route After routing, before render (ctx, next) A Response or await next()
response After render (ctx, response) A Response (the final one to send)
error When any prior phase throws (ctx, error) A Response (custom error page) or null

request — gate, redirect, forward broad protocols

Runs before the framework matches a route. Common uses:

  • Auth gates (redirect unauthenticated users)
  • Multi-tenant routing (look up a hostname → tenant)
  • Forwarding to a Durable Object (agents, sites)
  • Rate limiting

Do not build a large API switchboard here. If the path is an endpoint such as /api/v1/platform/sites/:id/files, put it in src/routes/api/v1/platform/sites/[id]/files.ts and export method handlers there.

auth: {
  async request(ctx, next) {
    if (ctx.url.pathname.startsWith('/admin/') && !ctx.locals.user) {
      return new Response('Unauthorized', { status: 401 });
    }
    return next();
  },
},

Returning a Response short-circuits — the framework stops here and skips routing/rendering. Calling next() continues the pipeline.

route — augment the matched route

Runs after the framework has matched a route (ctx.params is populated) but before render. Useful for:

  • Per-route logging that needs the matched pattern
  • Adding common props to ctx.locals
  • Final auth checks where the route pattern matters
audit: {
  async route(ctx, next) {
    ctx.locals.routePattern = ctx.params; // available to load() and actions
    return next();
  },
},

response — modify the outgoing response

Runs after the route has rendered. Receives the final Response; return a (possibly modified) one.

headers: {
  async response(ctx, response) {
    // Headers from `Response.redirect()` and similar are immutable. Clone
    // to a mutable shape before setting custom headers.
    const out = new Response(response.body, response);
    out.headers.set('X-App-Version', '2.4.1');
    return out;
  },
},

error — custom error pages

Runs when any earlier phase throws. Return a Response to override the framework's default 500 page; return null/undefined to let the framework handle it.

errors: {
  async error(ctx, error) {
    if (error instanceof MyDomainError) {
      return new Response(error.userMessage, { status: 400 });
    }
    return null; // let the framework render the default 500
  },
},

The middleware context

Every hook receives a MiddlewareContext:

interface MiddlewareContext {
  request: Request;              // the incoming request
  env: Env;                      // worker bindings (D1, KV, R2, DO, …)
  ctx: ExecutionContext;         // Cloudflare execution context
  url: URL;                      // parsed URL
  params: Record<string, string>; // route params (after `route` phase)
  locals: Record<string, any>;   // shared scratchpad across hooks
}

ctx.locals is the scratchpad — set values in one hook, read them in another, then read the same values in load() / actions / $server/* modules via locals.

auth: {
  async request(ctx, next) {
    ctx.locals.user = await loadUser(ctx);
    return next();
  },
},
// any $server/*.ts module or route action
import { locals } from 'koze:request';

export async function load() {
  const { user } = locals as App.Locals & { user: { id: string; email: string } | null };
  return { user };
}

Type App.Locals in src/app.d.ts to get IDE autocomplete on ctx.locals:

declare global {
  namespace App {
    interface Locals {
      user: { id: string; email: string } | null;
    }
  }
}
export {};

Execution order

Steps run in declaration order for request and route phases, reverse declaration order for the response phase (so the first request hook is the last to see the response — like Express middleware).

defineMiddleware({
  outer: { /* request → route → response → error */ },
  inner: { /* request → route → response → error */ },
});

Per-request flow:

outer.request → inner.request → routing → inner.route → outer.route
                              → render
                ← inner.response ← outer.response  (reverse order)

If inner.request throws, outer.error runs first (then inner.error).

If outer.request short-circuits with a Response, no other steps run; that response goes back through the (empty) response chain.

Real example: kuratchi.cloud

apps/web is the framework's reference dashboard. Its src/middleware.ts composes four steps:

export default defineMiddleware({
  // 1. Cold-start ORM migrations (idempotent, runs once per isolate).
  migrate: autoMigrate({ DB: adminSchema }),

  // 2. Auth — sessions, guards, rate-limit, turnstile, OAuth.
  auth: kyzenAuthMiddleware(authConfig),

  // 3. Per-tenant site rendering — short-circuits when the hostname maps to a site.
  sites: {
    async request(ctx, next) {
      const siteResponse = await resolveSiteRequest(ctx.request);
      if (siteResponse) return siteResponse;
      return next();
    },
  },

  // 4. AI agent routing — forwards /agents/* to per-session DOs.
  agents: {
    async request(ctx, next) {
      if (!ctx.url.pathname.startsWith('/agents/')) return next();
      // verify token, forward to the right DO
      // …
    },
  },
});

Read apps/web/src/middleware.ts for the full source.

Patterns

Conditional middleware (dev vs prod)

import { dev } from 'koze:environment';

defineMiddleware({
  auth: dev
    ? { async request(_ctx, next) { return next(); } } // bypass in dev
    : kyzenAuthMiddleware(authConfig),
});

dev is a compile-time constant (true during vite dev, false during vite build), so the unused branch tree-shakes out of the production worker.

Sharing data without globals

ctx.locals is per-request and reset between requests. Don't reach for module-level variables to share state — they leak across requests in long-lived isolates. The locals object is the right scratchpad.

Returning early without a Response

Hooks must return either a Response (short-circuit) or the result of await next() (continue). Returning undefined/null from request/route is treated as "did nothing, continue" by the framework, but explicit return next() is clearer.

Handling Response.redirect() and immutable headers

Response.redirect(...) and similar constructors produce responses with frozen headers. The framework already copies these to a mutable shape before passing them to your response hooks, so you can call response.headers.set(...) directly. If you build your own response in code (e.g. inside an action), wrap it with new Response(body, response) before mutating headers.

Where things live

Use case Where
Auth, ORM auto-migration, route guards, custom request logic src/middleware.ts
JSON APIs, webhooks, uploads, health checks, agent-visible HTTP endpoints src/routes/api/**/*.ts
Build-time options (security headers, directory overrides) koze({...}) in vite.config.ts
Cloudflare deployment (secrets, routes, custom bindings) wrangler.jsonc
ORM schema definitions src/server/schemas/*.ts (imported by middleware)

See Configuration for the full split.

  • Request APIs — the request context shape, redirect helpers, env helpers
  • API Routes — file-based HTTP endpoints under src/routes/api
  • Auth — wire kyzenAuthMiddleware into your project
  • ORM MigrationsautoMigrate as a middleware step