File-based routing
src/routes/index.get.ts GET /src/routes/users/index.get.ts GET /userssrc/routes/users/index.post.ts POST /userssrc/routes/users/[id].get.ts GET /users/:idsrc/routes/users/[id].patch.ts PATCH /users/:idsrc/routes/files/[...path].get.ts GET /files/*pathsrc/routes/admin/_middleware.ts middleware for /admin and belowimport { createApp, loadRoutes } from '@theoven/core'
const app = createApp()await loadRoutes(app, `${import.meta.dir}/routes`)await app.listen()loadRoutes(app, dir)
Section titled “loadRoutes(app, dir)”await loadRoutes(app, `${import.meta.dir}/routes`)Scans the directory, reads every route module, and registers what it finds. Three things about it are worth knowing.
It is awaited, and must finish before the first request. Routes are registered as they are
read, so calling listen() without awaiting this serves 404s for however long the scan takes.
import.meta.dir, not a relative path. A relative path resolves against the process’s working
directory, so the app works when started from the project root and fails from anywhere else.
In production it does not scan at all. oven build writes a manifest of static imports and
installs it; loadRoutes uses that instead of touching the filesystem. Your code is identical in
both, which is the only way the two stay in agreement — see Production builds.
Registration order is deterministic: files are sorted before they are read, so two machines produce the same route table. A route conflict that reproduces only on a colleague’s laptop is a miserable thing to chase.
Conventions
Section titled “Conventions”| Filename | Meaning |
|---|---|
name.get.ts |
GET /name |
index.get.ts |
GET for the containing directory |
[id].get.ts |
a path parameter, :id |
[...path].get.ts |
a catch-all, *path |
_middleware.ts |
middleware for this directory and below |
_anything.ts |
private — never a route |
*.test.ts |
ignored, so tests can sit beside routes |
The method lives in the filename rather than in named exports, so a route’s method is visible in
a directory listing and in git log --stat. One file is one endpoint, and grepping for
users/[id].patch finds it.
Two ways to write a route
Section titled “Two ways to write a route”Both work. They differ in how much typing you get:
export const auth = trueexport const body = z.object({ name: z.string() })
export default async (ctx) => ({ hello: ctx.body.name })export default defineRoute( { auth: true, body: z.object({ name: z.string() }) }, async (ctx) => ({ hello: ctx.body.name }), // ^ typed: string)The exports Oven reads are params, query, body, headers, response, auth, summary,
description and tags — the same keys defineRoute takes. Anything else exported is ignored.
Use the separate-export form when a route is small and you do not mind an untyped ctx; use
defineRoute — or routesFor — when you want the schema to type
the handler.
A route file
Section titled “A route file”import { defineRoute, NotFound } from '@theoven/core'import { z } from 'zod'
export default defineRoute( { summary: 'Update a user', tags: ['users'], params: z.object({ id: z.uuid() }), body: z.object({ name: z.string().min(1) }), response: { 200: UserSchema }, }, async (ctx) => { // Both are typed from the schemas above. const user = await db.user.update(ctx.params.id, ctx.body) if (!user) throw new NotFound() return user },)Those schemas drive validation, the handler’s types, and the generated OpenAPI document — one declaration, three jobs.
Middleware
Section titled “Middleware”A _middleware.ts applies to its directory and everything beneath it:
export default async (ctx, next) => { if (!ctx.token) throw new Unauthorized() return next()}Outermost first: a root _middleware.ts wraps everything, and admin/_middleware.ts wraps
/admin and below. Prefixes respect segment boundaries, so /admin never matches
/administrators.
Errors name the file
Section titled “Errors name the file”A convention only helps if breaking it says so clearly:
users.ts: route files must name their method, e.g. "users.get.ts". Prefix the file with "_" if it is not a route.
[..path].get.ts: "[..path]" looks like a mistyped catch-all. Write it as "[...path]".
users.get.ts: the default export is string, not a function. Export the handler itself, not the result of calling it.Production builds
Section titled “Production builds”oven build writes a manifest with static imports rather than scanning at boot:
import * as r0 from './routes/index.get.ts'import * as r1 from './routes/users/[id].get.ts'
export function registerRoutes(app: App): void { /* ... */ }Two reasons. Production should not walk the filesystem on every cold start, and a bundler can only tree-shake route modules it can see imported by name.
Typing ctx with your bricks
Section titled “Typing ctx with your bricks”defineRoute types ctx.params, ctx.query and ctx.body from the schema beside them. It
cannot know what bricks your app registered, though — a route file and app.ts are separate
modules, and nothing connects them. So ctx.db in a route file is unknown.
Bind it once per project:
import { routesFor } from '@theoven/core'import type { app } from './app'
export const route = routesFor<typeof app>()import { route } from '../../route'import { z } from 'zod'import { notes } from '../../schema'
export default route( { query: z.object({ limit: z.coerce.number().int().max(100).default(20) }) }, (ctx) => ctx.db.select().from(notes).limit(ctx.query.limit), // ^ the Drizzle client, typed from your schema)The import of app is type-only, so it does not create a cycle with the module that loads
these files — app.ts loads the routes, and the routes only reference its type.
Use defineRoute when a route touches no bricks; route everywhere else. They are the same
function, one with the context bound.
When the filesystem is not enough
Section titled “When the filesystem is not enough”File routing cannot express one guard shared across a group, the same routes mounted under two prefixes, or routes shipped from a package. Routers can, and the two compose — most apps use file routing for their own endpoints and a router for the exceptions.