Skip to content

File-based routing

src/routes/index.get.ts GET /
src/routes/users/index.get.ts GET /users
src/routes/users/index.post.ts POST /users
src/routes/users/[id].get.ts GET /users/:id
src/routes/users/[id].patch.ts PATCH /users/:id
src/routes/files/[...path].get.ts GET /files/*path
src/routes/admin/_middleware.ts middleware for /admin and below
src/index.ts
import { createApp, loadRoutes } from '@theoven/core'
const app = createApp()
await loadRoutes(app, `${import.meta.dir}/routes`)
await app.listen()
src/index.ts
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.

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.

Both work. They differ in how much typing you get:

separate exports — simple, untyped ctx
export const auth = true
export const body = z.object({ name: z.string() })
export default async (ctx) => ({ hello: ctx.body.name })
defineRoute — one call, ctx typed from the schema
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.

src/routes/users/[id].patch.ts
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.

A _middleware.ts applies to its directory and everything beneath it:

src/routes/admin/_middleware.ts
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.

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.

oven build writes a manifest with static imports rather than scanning at boot:

.oven/routes.ts (generated)
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.

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:

src/route.ts
import { routesFor } from '@theoven/core'
import type { app } from './app'
export const route = routesFor<typeof app>()
src/routes/notes/index.get.ts
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.

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.