Core Concepts
The file convention
- GET.ts
- GET.ts
- GET.ts
- POST.ts
- GET.ts
- DELETE.ts
resolves to:
| File path | Route |
|---|---|
routes/GET.ts | GET / |
routes/health/GET.ts | GET /health |
routes/users/GET.ts | GET /users |
routes/users/POST.ts | POST /users |
routes/users/[id]/GET.ts | GET /users/:id |
routes/users/[id]/DELETE.ts | DELETE /users/:id |
routes/users/[id]/posts/[postId]/GET.ts | GET /users/:id/posts/:postId |
The rules
- Only exact
GET.ts/POST.ts/PUT.ts/PATCH.ts/DELETE.tsfilenames become routes. Everything else in a route folder is invisible to the scanner. - Files starting with
_are ignored entirely — use_shared/or_helpers.tsfor code that isn’t itself a route (as in the tree above). [id]becomes:id. Nest them freely —[id]/posts/[postId]works exactly like you’d expect.- Static beats dynamic.
routes/users/me/GET.tswill always win overroutes/users/[id]/GET.tsfor a request to/users/me— you never need to special-case it in the dynamic handler.
This is the whole convention. There is no second, more advanced routing syntax hiding behind it for edge cases — if you need something the folder structure can’t express, that’s what middleware and the handler body are for.
The layers
Scanner → RouteGraph → Adapter → your framework
↑ ↑
Watcher Docs UI (opt-in)
↑
Client (generated)- Scanner walks
routes/once, atgraph.load(), and dynamically imports every matching file. RouteGraphholds the resulting route table — validatedconfigexports, resolved paths, everything an adapter needs.- Adapter (
@routegraph/express,/hono,/fastify,/elysia,/koa) turns that table into a router native to your framework. - Watcher, Docs, Client are all optional layers on top — see their own pages.
The middleware chain
Request → global middleware (yours, registered on the app) → route-level
middleware (declared in that route’s config) → Zod validation (if a
request schema exists) → your handler. Validation always runs after
middleware and before your handler — a request that fails validation
never reaches your code.
NormalizedRequest / NormalizedResponse
Every adapter hands your handler the same shape, regardless of framework:
interface NormalizedRequest<Config> {
params: InferParams<Config> // typed from config.request.params
query: InferQuery<Config>
body: InferBody<Config>
headers: Record<string, string>
raw: unknown // the underlying framework request — escape hatch
}.raw exists on purpose. RouteGraph normalizes the 95% case; when you need
something framework-specific (streaming a response, reading a raw socket),
.raw is the door out.