Skip to content

A source folder's API runs on one backend framework - Hono, H3 or Koa.

  • Hono - exceptional performance, runs unchanged on Node, Deno, Bun and edge platforms.
  • H3 - comparable performance and reach, built around Web standards.
  • Koa - battle-tested Node ecosystem, elegant async/await middleware.

The decision matters less than it looks. Route layout, middleware composition, payload validation, fetch clients, all behave identically whichever you pick.

What changes is the context object your handlers receive - reading a body, setting a response, raising an error stay your framework's own idioms, untouched.

What's in api/

Creating a source folder with a backend seeds a small, fixed set of files. Each is a real source file you own - they are written once, never re-seeded.

text
src/<folder>/api/
├── app.ts                -> builds the backend app instance
├── server.ts             -> standalone server entry
├── dev.ts                -> dev-only hooks
├── errors.ts             -> the central error handler
├── use.ts                -> global middleware (every route in this folder)
├── env.d.ts              -> global context/state types, custom slots

└── users/                ── a route folder ──
    ├── use.ts            -> middleware for /users and everything under it
    ├── index.ts          -> the route  ->  /api/users
    ├── types.ts          -> colocated helper, NOT a route
    └── [id]/
        └── index.ts      -> the route  ->  /api/users/:id

Foundation files

FileWhat it isWhen you touch it
app.tsBuilds the app with appFactory(). The callback hands you the native Hono/H3/Koa instance - this is where the error handler is registered and where app middleware or any framework plugin goes.Adding app middleware; enabling debug
server.tsThe standalone entry that boots app.ts - what node dist/<folder>/api/server.js runs in production.Rarely
dev.tsDev-only hooks: requestHandler() returns the handler the dev server dispatches to (override it for WebSockets or custom dispatch), and teardownHandler() runs before every reload - close DB connections and sockets here or they leak across restarts.WebSockets; connection cleanup
errors.tsThe central error handler, registered by app.ts. The default distinguishes ValidationError and HTTPError, then content-negotiates JSON or plain text.Customizing error responses
use.tsGlobal middleware - runs for every route in this folder.Request id, auth, permission checks
env.d.tsModule augmentation for folder-wide types: DefaultVariables/DefaultBindings (Hono), DefaultContext (H3), DefaultState/DefaultContext (Koa), plus custom slot names.Typing ctx.state / bindings

Only app.ts differs between backends, and only in how the error handler attaches:

ts
// Hono: api/app.ts
export default appFactory(routes, ({ app }) => {
  app.onError(defaultErrorHandler);
});

Inside a route folder

FileWhat it is
index.tsThe route. Default-exports defineRoute(...); its folder path becomes the URL.
use.tsCascading middleware for this folder and everything beneath it. Exports UseT to extend the typed context downward.
anything elseA colocated helper - schemas, types, queries, tests. Never a route, never scanned.

index.ts and use.ts are the only two filenames the backend watcher acts on. That is the whole convention: one URL per folder, one file that defines it.

Derived code - validators, the route table, fetch clients, the OpenAPI spec - never lands here. It lives in lib/, is git-ignored, and you neither read nor edit it. Details ›

Defining Endpoints

Every API route exports a defineRoute definition as its default export. The factory function receives HTTP method builders and use for middleware, and returns an array of handlers. Destructure only what you need:

api/users/[id]/index.ts
ts
import { defineRoute } from "_/api";

export default defineRoute<"users/[id]">(({ GET }) => [
  GET(async (ctx) => {
    // handle GET /users/:id
  }),
]);

Multiple methods in one route:

api/users/index.ts
ts
export default defineRoute<"users">(({ GET, POST, PUT, DELETE }) => [
  GET(async (ctx) => { /* retrieve */ }),
  POST(async (ctx) => { /* create */ }),
  PUT(async (ctx) => { /* update */ }),
  DELETE(async (ctx) => { /* delete */ }),
]);

This method-based routing style draws inspiration from Sinatra - the Ruby framework that pioneered it back in 2007.

Handler order doesn't matter - requests are dispatched by HTTP method. Undefined methods return 405 Method Not Allowed automatically.

Available builders: HEAD, OPTIONS, GET, POST, PUT, PATCH, DELETE.

HEAD is served by your GET handler

HEAD is the one exception to the 405 rule. A route that defines GET but not HEAD still answers HEAD requests: they are dispatched to the GET handler and validated against its schemas, with the body dropped as the HTTP spec requires. Define HEAD explicitly only when you want to override that. Hono is the exception: its router ignores any HEAD handler you define, so the fallback to GET always wins there.

The Route Name Type Argument

defineRoute<"users/[id]"> restates the path the file already lives at, which looks redundant.

It isn't - and it's worth one minute to understand why, because it explains a lot of what follows.

The routing itself never needs it. The URL comes from the file's location, full stop. The string is there for TypeScript, which cannot see the file system.

Everything KosmoJS knows about a route is placed into a RouteMap in lib/, keyed by route name:

lib/front/@api/routes.ts
ts
export type RouteMap = {
  "users/[id]": {
    paramsDefaults: [string],           // params, in path order
    paramsMappings: { id: 0 },          // name -> position
    cascadingState: UseT_apiUse & UseT_apiUsersUse,  // merged use.ts context
  },
  // ...every other route
};

The name is how the handler looks itself up in that map. That single lookup is what gives you:

  • ctx.validated.params typed - which params exist, in what order, refined to what.
  • Cascading context typed - the merged UseT of every use.ts above this route, which is why ctx.get("user") is typed without importing anything. Details ›
  • The params refinement tuple checked against the real params - a tuple longer than the route has parameters is a compile error.

Because there is no runtime argument carrying it, TypeScript has nothing to infer it from - so the type argument is required, and you write it once when the file is created.

In practice, you don't: the seeded boilerplate already contains the correct name.

What if it's wrong?

It can't silently drift. defineRoute<R> is constrained as R extends keyof RouteMap, so a name that doesn't match a real route is a compile error, not a runtime surprise.

Rename api/users/ to api/people/ and the stale defineRoute<"users/[id]"> fails to typecheck immediately - the same refactor-as-a-checklist property the typed Link component gives you on the frontend.

The name is the route path relative to api/, without the trailing index.ts - so api/users/[id]/index.ts is "users/[id]", and api/index/index.ts is "index".

Pages have no equivalent: a page component is an ordinary default export, and its routing is resolved by the framework's own router, so there is nothing to look up.

Where routes end up: frontend.base and backend.base

Each side of a source folder declares the URL prefix it owns, and each one is a full path. The two are resolved independently:

API route URL  = join(backend.base, routeName)
page URL       = join(frontend.base, pagePath)

That is the one thing to hold on to; everything in the table follows from it.

frontend.basebackend.baseAPI routes live atpages live atNote
//api/api/<route>/<page>app at the root
/admin/admin/api/admin/api/<route>/admin/<page>the API nested under the folder
/admin/api/v2/api/v2/<route>/admin/<page>nothing requires them to nest
none/api/api/<route>nonean API-only folder
/docsnonenone/docs/<page>a pages-only folder

Nesting the API under the pages prefix is a convention, not a rule - useful when a folder should be relocatable as a unit, since moving frontend.base means moving backend.base with it.

An API-only folder

Omit frontend entirely and give the backend the prefix it should own:

ts
// src/api/kosmo.config.ts
export default defineConfig({
  backend: {
    stack: "hono",
    base: "/api",
  },
  validation: true,
});
// src/api/api/emails/index.ts  ->  /api/emails

The api/ dir on disk never appears in the URL - it only separates server routes from pages/.

How requests are dispatched

The dev server and the built dist/run.js route by prefix: more specific prefixes win, and a folder's backend.base is ranked ahead of its frontend.base - so /front/api/users reaches the API even though /front also matches.

A folder at base: "/" catches only what no other folder claims. The server prints the prefix table on start; read it when a route lands somewhere unexpected:

  /admin/api   -> admin
  /api/        -> api
  /webhooks/   -> webhooks
  /admin       -> admin
  /            -> docs

Type Safety

Parameters, payloads, and responses are all typed through type arguments - the same definitions drive both compile-time checking and runtime validation. No separate schema language, no DSL switching. Details ›

Middleware

The use function gives you fine-grained middleware control at the route level, complementing global and cascading middleware. Details ›

Released under the MIT License.