On the backend, a route is more than a URL. It is a URL plus the set of HTTP methods it answers, plus the middleware inherited from the folders above it, plus the types it drives the validation, fetch clients and OpenAPI spec from.
KosmoJS derives all four from one place: the folder an index.ts lives in.
This page covers the backend specifics. The shared fundamentals are in Directory-Based Routing, and the parameter syntax is in Parameters. For how pages are routed, see Frontend Routing.
One folder, one URL
api/
├── index/
│ └── index.ts -> /api
├── users/
│ ├── index.ts -> /api/users
│ ├── use.ts -> middleware for /api/users and below
│ ├── types.ts -> helper, not a route
│ └── [id]/
│ ├── index.ts -> /api/users/:id
│ └── {action}/
│ └── index.ts -> /api/users/:id/:action
└── docs/
└── {...path}/
└── index.ts -> /api/docs/*Only two filenames carry meaning inside api/:
| File | Role |
|---|---|
index.ts | Defines the route. Its folder path is the URL. |
use.ts | Cascading middleware for this folder and everything beneath it. |
Everything else in a route folder - schemas, queries, tests, fixtures - is yours. It is never scanned and never mistaken for a route, which is the practical payoff of folder-per-route over file-per-route.
The URL prefix
The folder path is only half of the URL. The other half is backend.base from the source folder's config:
API route URL = join(backend.base, routeName)export default defineConfig({
backend: {
stack: "hono",
base: "/api",
},
});The api/ directory on disk never appears in the URL - it only separates server routes from pages/. backend.base is a full path, independent of frontend.base, so nothing forces the two to nest. Details ›
When several source folders share one server, requests are dispatched by prefix - longest first, with a folder's backend.base ranked ahead of its frontend.base. A request for /admin/api/users reaches the admin API even though /admin also matches the admin pages.
Routes are method tables
A route file default-exports defineRoute(...). Its callback receives one builder per HTTP method and returns the handlers the route answers:
import { defineRoute } from "_/api";
export default defineRoute<"users">(({ GET, POST }) => [
GET(async (ctx) => { /* list */ }),
POST(async (ctx) => { /* create */ }),
]);Available builders: HEAD, OPTIONS, GET, POST, PUT, PATCH, DELETE.
Dispatch is by method, so the order of handlers in the array does not matter. This one file is the whole REST surface of /api/users - there is no second file per method and no router table that has to agree with it.
What a request can get back before your handler runs
The route, not the handler, decides some responses:
- Method not defined ->
405 Method Not Allowed. DefineGETandPOST, and aDELETEis rejected for you. HEADfalls back toGET. A route with aGEThandler but noHEADanswers HEAD requests through theGEThandler, validated against the same schemas, with the body dropped. DefineHEADexplicitly only to override that. Hono is the exception: its router ignores aHEADhandler, so theGETfallback always wins there.- No route matched -> your framework's 404. Only
api/app.tssees these requests, along with preflights and405s. Route-level and global middleware never do, which is why CORS lives there.
The route name
defineRoute<"users/[id]"> restates the path the file already lives at. The URL does not need it - TypeScript does, because it cannot see the file system.
The name is the route's key into the derived RouteMap, and that lookup is what types ctx.validated.params and the merged context of every use.ts above the route.
The rule is mechanical: the path relative to api/, without the trailing index.ts.
| File | Route name |
|---|---|
api/index/index.ts | "index" |
api/users/index.ts | "users" |
api/users/[id]/index.ts | "users/[id]" |
api/docs/{...path}/index.ts | "docs/{...path}" |
You never type it by hand: the seeded boilerplate contains the correct name. It also cannot drift - a name that matches no real route is a compile error, so renaming api/users/ to api/people/ flags every stale defineRoute<"users/..."> at once.
Parameters
The three syntaxes - required [id], optional {id}, splat {...path} - behave identically on every backend. What is specific to the backend is what your handler receives.
| Syntax | ctx.validated.params |
|---|---|
[id] | the segment, always present |
{id} | the segment, or undefined when absent |
{...path} | an array of segments |
Parameters arrive validated, not raw. Refine them with a tuple, one position per parameter, in path order:
type UserAction = "retrieve" | "update" | "delete";
export default defineRoute<"users/[id]/{action}", [
number, // id
UserAction, // action
]>(({ GET }) => [
GET(async (ctx) => {
const { id, action } = ctx.validated.params; // number, UserAction | undefined
}),
]);A request that fails the refinement is rejected before your handler runs. Details ›
Keep the tuple brackets inline
The [] of the params tuple must be written in the defineRoute type arguments. Aliases inside the brackets are fine; hiding the brackets behind a type alias makes the tuple unreadable and every request is rejected. Details ›
The raw, untouched params still exist on the framework's own context:
ctx.req.param()event.context.paramsctx.paramsOne handler for list and detail
An optional parameter lets a single route serve both the collection and the item:
export default defineRoute<"users/{id}", [number]>(({ GET }) => [
GET(async (ctx) => {
const { id } = ctx.validated.params;
if (id === undefined) {
// GET /api/users - list
} else {
// GET /api/users/123 - detail
}
}),
]);When the two cases have different methods, payloads or middleware, prefer two routes - users/index.ts and users/[id]/index.ts - so each gets its own methods and its own types.
Matching rules
- Static beats dynamic.
users/mewins overusers/[id]for/api/users/me, whatever the file order. - Optional parameters must not be followed by required ones.
users/{section}/{subsection}is fine;users/{optional}/[required]is not. - An optional segment before a static one can swallow it. With
properties/{city}/filters, the URL/api/properties/filtersbinds{city}to"filters", then looks for a secondfilterssegment and 404s. Add an explicit static sibling (properties/filters/index.ts) and static priority resolves it.
Mixed segments and power syntax
Backends can express URLs that frontend routers mostly cannot - file extensions, composite segments:
files/[name].[ext] -> /api/files/report.pdf
profiles/[id]-[data].json -> /api/profiles/1-posts.jsonAny parameter name containing non-alphanumeric characters is passed through as a raw path-to-regexp v8 pattern - the power syntax:
api/{v:version}/users -> /api/users or /api/v2/usersSupport differs per backend, and this is the one place the stack choice changes your routes:
| Hono | H3 | Koa | |
|---|---|---|---|
[id], {id}, {...path} | ✅ | ✅ | ✅ |
| Mixed segments | ⚠️ partial | ⚠️ partial | ✅ full |
| Power syntax | ⚠️ matches, params renamed _0abc | ❌ won't match | ✅ full |
A practical rule
Mixed segments are workable on all three backends; reach for power syntax only on Koa. If a route silently 404s, check the support matrix before debugging the handler.
Routes inherit from their folders
The folder hierarchy that builds the URL also builds the middleware chain. A request to /api/users/account runs, in order:
api/app.ts app middleware - every request, matched or not
edge:* middleware first in the matched route's chain, before validation
validation params, query, headers, cookies, body
api/use.ts global
api/users/use.ts parent folder
api/users/[id]/use.ts current folder
route use() inline, inside defineRoute
handlerParent middleware always runs before child middleware, and a child cannot skip its parents. Moving a route folder moves the middleware it inherits with it - the structure is the wiring.
Because a cascading use.ts also runs for sibling routes, keep it generic: a param like id may be undefined there.
Aliases
A route's URL comes from its folder, which keeps URLs predictable. When an outside consumer needs a different one - /feed.xml, /healthz, a legacy path - backend.alias serves an existing route at an additional URL:
backend: {
stack: "hono",
base: "/api",
alias: {
"/feed.xml": "rss",
"/members/[id]": "users/[id]",
},
}The key is the whole path - it is not prefixed by backend.base. The value is the route name.
An alias is another entry for the same handler, not a redirect, so the route's middleware and validation come along unchanged.
When the alias carries parameters, they must match the target's by name and kind; a mismatch is not a startup error, the request just 404s.
Native routing, nothing in between
Routing is overall native, path-to-regexp is used only at build time, to turn your directory structure into route definitions.
At runtime those routes are registered with your framework's own router - Hono's, H3's or Koa's - exactly as you would have registered them by hand.
No KosmoJS router sits between a request and the framework's matching logic.
build time runtime
────────── ───────
api/users/[id]/index.ts -> Hono / H3 / Koa router
│
└── parsed via path-to-regexpEverything the framework documents about its router - matching order, OPTIONS handling, error propagation - applies as written.
KosmoJS is the chassis; the framework is the engine.
Inspecting what was registered
While you work, the dev server prints the prefix table - which source folder owns which path - so a route landing somewhere unexpected is usually visible there.
To list the registered API routes, pass debug to appFactory in api/app.ts:
export default appFactory(
routes,
{ debug: true },
({ app }) => {
// ...
},
);Quick reference
| You want | You write |
|---|---|
An endpoint at /api/orders | api/orders/index.ts |
The base route /api | api/index/index.ts |
| A required / optional / catch-all segment | [id] / {id} / {...path} |
| Several methods on one URL | Several handlers in one defineRoute |
| A typed, validated param | A tuple as the second type argument |
| Auth for a whole section | A use.ts in that section's folder |
| A second public URL for a route | An entry in backend.alias |
| A helper next to a route | Any file other than index.ts / use.ts |