Beyond the standard HTTP method handlers, you often need to run custom middleware - code that executes before your main handler to perform tasks like authentication, logging, or data transformation.
Basic Usage
KosmoJS provides the use function for applying middleware. The same API applies identically to all frameworks, only the middleware internals slightly differs by framework:
export default defineRoute<"example">(({ GET, POST, use }) => [
use(async (ctx, next) => {
// runs for both GET and POST
return next();
}),
GET(async (ctx) => { /* ... */ }),
POST(async (ctx) => { /* ... */ }),
]);Middleware must call next() to pass control to the next layer. Skipping next() short-circuits the chain - useful for early rejections.
Execution Order (Onion Model)
Middleware runs in definition order going in, then unwinds in reverse after the handler.
Consider this example:
export default defineRoute<"example">(({ POST, use }) => [
use(async (ctx, next) => {
console.log("First middleware");
await next();
console.log("First middleware after next");
}),
use(async (ctx, next) => {
console.log("Second middleware");
await next();
console.log("Second middleware after next");
}),
POST(async (ctx) => {
console.log("POST handler");
// ...
}),
]);When a POST request arrives, the execution order is like:
First middleware
Second middleware
POST handler
Second middleware after next
First middleware after nextPositioning note: All
usecalls run before method handlers regardless of where they appear in the array. Defininguseafter a handler doesn't change this:
export default defineRoute<"example">(({ use, GET, POST }) => [
use(firstMiddleware),
GET(async (ctx) => { /* ... */ }),
POST(async (ctx) => { /* ... */ }),
use(secondMiddleware), // still runs BEFORE handlers
]);Global Middleware
Wiring same middleware into every route is tedious and dangerous.
Use global middleware instead - add middleware to api/use.ts and it runs for every route - no imports, no registration, nothing to wire:
// Hono: api/use.ts
import { use } from "_/api";
export default [
// will run on every route
use(async function requestId(ctx, next) {
ctx.set("requestId", crypto.randomUUID());
return next();
}),
];This is the place for work that belongs to routes: loading the current user onto the context, permission checks, audit logging of writes - things that need a route to exist, and that want the request already validated.
Anything narrower belongs in a cascading use.ts for a subtree, or in the route's own use.
api/use.ts is skipped for non-route responses
A preflight OPTIONS, or a 405 for a method the route doesn't implement, is answered before any route chain runs - so global middleware never sees it.
Which makes this the wrong home for CORS: the preflight would never reach your middleware, and the browser would reject the request before it ever sent the real one.
CORS - and anything else that must appear on every response - belongs to app middleware.
Method-Specific Middleware
Use the on option to restrict middleware to specific HTTP methods:
// Hono: api/example/index.ts
export default defineRoute<"example">(({ GET, POST, use }) => [
use(async (ctx, next) => {
ctx.set("user", await verifyToken(ctx.req.header("authorization")));
return next();
}, {
on: ["POST"],
}),
GET(async (ctx) => {
// no auth required
}),
POST(async (ctx) => {
// ctx.get("user") is available
}),
]);Slot Composition
Slots are named positions in the middleware chain. Middleware with the same slot name replaces earlier middleware at that position - useful for overriding global defaults per-route.
A global logger defined in api/use.ts:
export default [
use(
async (ctx, next) => { /* global logger */ },
{ slot: "logger" },
),
];Override it for a specific route:
export default defineRoute<"upload">(({ POST, use }) => [
use(
async (ctx, next) => {
// custom logger for this route only
},
{ slot: "logger" },
),
POST(async (ctx) => { /* ... */ }),
]);When overriding via slot, explicitly set on option if needed - it doesn't inherit from the middleware being replaced.
Custom slot names, like logger, should be added to api/env.d.ts:
export declare module "_/api" {
// ...
interface UseSlots {
logger: string;
}
}Then use it anywhere:
use(async (ctx, next) => { /* ... */ }, { slot: "logger" })Some slot names are reserved and already positioned in the chain - the edge: family is the one you are likely to reach for.
Promoting to the Route Edge
Any use() entry claiming an edge: prefixed slot in api/use.ts, in a cascading use.ts, or in the route itself - is lifted out of its usual position and run first in the matched route's chain instead, ahead of validation. That is edge middleware.
Overriding Validation
Validation is middleware too, and every target sits in a reserved slot - so any of them can be replaced exactly the way you replace a logger.
Available validation slots, each reserved for a specific validation target:
validate:params
validate:query
validate:headers
validate:cookies
validate:json
validate:form
validate:raw
validate:responseThese are reserved slots - no
UseSlotsdeclaration inapi/env.d.tsneeded.
Claim one and your middleware runs instead of the built-in validator for that target, say an endpoint accepts a body no schema can describe:
export default defineRoute<"import">(({ POST, use }) => [
use(async (ctx, next) => {
// NDJSON - one JSON document per line
const body = await ctx.bodyparser.raw<string>();
// ...
return next();
}, {
slot: "validate:json",
}),
POST(async (ctx) => { /* ... */ }),
]);Everything else about slots still applies: declare it in api/use.ts to replace validation folder-wide, in a cascading use.ts for a subtree, or in the route itself for one endpoint.
What You Take Over
One target, and only that one. Overriding validate:json replaces the JSON validator and nothing else - ctx.validated.params, .query, .headers and .cookies are still filled in by their own validators, still before your handler runs.
What you give up is that target's entry in ctx.validated. The built-in validator loads the data, checks it, and publishes the result; yours is only expected to check. So ctx.validated.json stays unset, and the handler reads the body itself.
Which costs nothing, because the parsers are shared:
Parsers are lazy and cached
ctx.bodyparser.<target>() and ctx.metaparser.<target>() each run at most once per request and return the cached result afterwards. Call them wherever you like - in your validator, in the handler, in both. The request stream is read once no matter how many times you ask for it.
So the handler above just asks again, and gets the body your validator already parsed:
POST(async (ctx) => {
const records = await ctx.bodyparser.raw<string>()
// ...
}),Overriding validate:response works the same way in reverse: your middleware decides what a valid response looks like, and the built-in check no longer runs.
App Middleware
The outermost layer, and the only one KosmoJS doesn't compose for you. api/app.ts hands you the Hono / H3 / Koa instance itself, so anything the framework can do at app level, you do here - written exactly as that framework's own docs describe:
// Hono: api/app.ts
import appFactory, { routes } from "_/api:factory";
import defaultErrorHandler from "./errors";
export default appFactory(routes, ({ app }) => {
app.onError(defaultErrorHandler);
app.use(async (c, next) => {
const started = performance.now();
await next();
console.log([ c.req.method, c.req.path, performance.now() - started ]);
});
});This layer runs first, on every request, whether or not a route matched - so it is the only place that can answer a 404, or see traffic for URLs your api/ tree knows nothing about.
It is also the bluntest layer. There are no slots here and nothing downstream can replace it, and KosmoJS doesn't compose it.
It runs before any route chain, so the KosmoJS helpers aren't on the context yet: no ctx.validated, no ctx.metaparser, no ctx.bodyparser.
Read the request through the framework's own API - or move the check into edge middleware, which runs inside the route's chain and has them.
Request logging, CORS, tracing, rate limiting by IP: things that are true of the connection rather than of the route.
CORS
A preflight OPTIONS is answered before any route matches, so it never reaches a cascading use.ts - CORS has to be registered here.
cors below is whatever CORS middleware you wire up - hono/cors, @koa/cors, or your own header-setting middleware. What differs per backend is only how the error handler attaches:
// Hono: api/app.ts
import appFactory, { routes } from "_/api:factory";
import defaultErrorHandler from "./errors";
import { cors } from "hono/cors";
export default appFactory(routes, ({ app }) => {
app.onError(defaultErrorHandler);
app.use(cors({ origin: "https://example.com" }));
});