A folder with backend: { stack: "h3" }. Everything here is H3-specific; the routing model, validation and fetch clients are the same for every backend.
The handler argument is an event, not a context - it is the same object H3 passes everywhere, and KosmoJS adds event.validated to it.
What the folder contains
| File | H3-specific? |
|---|---|
api/app.ts | yes - app.use(onError(...)) plus app middleware |
api/errors.ts | yes - may return a Response, object or string |
api/dev.ts | yes - toNodeHandler(app) from h3/node |
api/env.d.ts | yes - augments DefaultContext |
api/use.ts | shared shape, H3 idioms inside |
api/server.ts | no |
| route files | shared shape, H3 idioms inside |
Attaching the error handler
H3 wraps the handler in its own onError helper, registered as middleware:
// H3: api/app.ts
export default appFactory(routes, ({ app }) => {
app.use(onError(defaultErrorHandler));
});The handler may return a Response, a plain object or a string. Branch on event.url when behaviour should differ per route.
Responding from a handler
Return the value - H3 serializes it:
// H3: api/users/[id]/index.ts
import { HTTPError } from "@kosmojs/core/errors";
export default defineRoute<"users/[id]", [number]>(({ GET }) => [
GET(async (event) => {
const { id } = event.validated.params;
const user = await db.users.find(id);
if (!user) throw new HTTPError([404, "User not found"]);
return user;
}),
]);| Task | H3 |
|---|---|
| read a header | event.req.headers.get("authorization") |
| respond | return value |
| set a status | event.res.status = 400 |
| carry per-request state | event.context.x = value |
Global middleware
// H3: api/use.ts
import { use } from "_/api";
export default [
// will run on every route
use(async function requestId(event, next) {
event.context.requestId = crypto.randomUUID();
return next();
}),
];State goes on event.context.
Method-specific middleware
// H3: api/example/index.ts
export default defineRoute<"example">(({ GET, POST, use }) => [
use(async (event, next) => {
event.context.user = await verifyToken(event.req.headers.get("authorization"));
return next();
}, {
on: ["POST"],
}),
GET(async (event) => {
// no auth required
}),
POST(async (event) => {
// event.context.user is available
}),
]);App middleware
Registered on the native instance in api/app.ts, this layer runs before any route chain - so KosmoJS helpers such as ctx.validated are not available here:
// H3: api/app.ts
import { onError } from "h3";
import appFactory, { routes } from "_/api:factory";
import defaultErrorHandler from "./errors";
export default appFactory(routes, ({ app }) => {
app.use(onError(defaultErrorHandler));
app.use(async (event, next) => {
const started = performance.now();
await next();
console.log([ event.req.method, event.url.pathname, performance.now() - started ]);
});
});Order matters: the error handler goes first, or middleware registered before it sits outside its reach.
Folder-level middleware and UseT
A use.ts in an api/ subfolder wraps that subtree and may extend the context. Export UseT to type what it adds - the global api/use.ts is the exception and does not export one:
// H3: api/users/use.ts
import { use } from "_/api";
export type UseT = {
user: { id: number; role: "admin" | "user" };
};
export default [
use<UseT>(async (event, next) => {
const token = event.req.headers.get("authorization")?.replace("Bearer ", "");
// validate before adding to context - UseT promises this property exists
if (!token) throw new HTTPError({ status: 401, message: "Authentication required" });
event.context.user = await verifyToken(token);
return next();
})
];Routes underneath destructure event.context, fully typed. Note H3's HTTPError takes an object - { status, message } - where KosmoJS's own HTTPError from @kosmojs/core/errors takes a tuple.
Third-party middleware
Middleware that should join the route chain goes through use():
// H3: api/users/use.ts
import { use } from "_/api";
export default [
use(async function noStore(event, next) {
event.res.headers.set("cache-control", "no-store");
return next();
}),
];H3 ships fewer off-the-shelf middleware packages than Hono or Koa, so more of this layer tends to be hand-written against event.req / event.res.
Middleware that must see every request - CORS included - goes on the native instance in api/app.ts instead, because the route chain is skipped for preflight OPTIONS and for 405 responses.
Edge middleware slots
Slots are not framework-specific - edge:auth and edge:ratelimit behave the same on every backend. What differs is the code inside the handler.
Auth that must run before everything, including unmatched routes, belongs on the native instance:
// H3: api/app.ts
import { onError } from "h3";
import appFactory, { routes } from "_/api:factory";
import defaultErrorHandler from "./errors";
export default appFactory(routes, ({ app }) => {
app.use(onError(defaultErrorHandler));
app.use(async (event, next) => {
const token = event.req.headers.get("authorization")?.replace("Bearer ", "");
if (!token) return new Response("Authentication required", { status: 401 });
return next();
});
});Dev hooks
api/dev.ts tells the dev server how to dispatch:
// H3: api/dev.ts
import { toNodeHandler } from "h3/node";
import { devSetup } from "_/api:factory";
import app from "./app";
export default devSetup({
requestHandler() {
return toNodeHandler(app);
},
});teardownHandler() runs before every reload - close DB connections and sockets there or they leak across restarts.
Validation errors
ValidationError reaches api/errors.ts like any other throw:
// H3: api/errors.ts
if (error instanceof ValidationError) {
event.res.status = 400;
return { error: error.errorMessage };
}Typing the context
H3 has a single surface, so there is one interface to augment:
// H3: api/env.d.ts
export declare module "_/api" {
interface DefaultContext {
permissions: Array<"read" | "write" | "admin">;
}
}Hono augments DefaultVariables/DefaultBindings, Koa DefaultState/DefaultContext.
Per-route context typing
Beyond the global env.d.ts, a single route widens its own context through defineRoute's type parameters:
// H3: api/users/[id]/index.ts
defineRoute<
"route-name",
ParamsTuple, // param refinements
Context, // route-specific context properties
>H3 takes three - there is only one context surface to widen:
// H3: api/users/[id]/index.ts
export default defineRoute<
"users/[id]",
[number],
{ permissions: Array<"read" | "write"> }, // event.context.permissions
>(({ GET }) => [
GET(async (event) => {
const { id } = event.validated.params;
const { permissions } = event.context;
}),
]);Route templates
backend.templates seeds new route files by route-name pattern. The template is a string, so it has to be written in this backend's idiom:
// H3: kosmo.config.ts
const template = `
import { defineRoute } from "_/api";
export default defineRoute<"{{route.name}}">(({ GET }) => [
GET(async (event) => {
// return the value directly - objects serialize as JSON
return { ok: true };
}),
]);`;{{route.name}} is substituted per route.
Worth knowing
- The argument is named
eventthroughout H3's own docs and in the seeded code. Handlers written againstctxwill typecheck, since the name is yours to choose, butctx.req.header(...)is Hono's API and does not exist on an H3 event.