RouteGraph v1.0.0 is out. It's stable, it's typed, it's not going to rewrite your framework of choice into a decorator soup. → Try it in 90 seconds
DocsCore Concepts

Core Concepts

The file convention

    • GET.ts
      • GET.ts
      • GET.ts
      • POST.ts
        • GET.ts
        • DELETE.ts

resolves to:

File pathRoute
routes/GET.tsGET /
routes/health/GET.tsGET /health
routes/users/GET.tsGET /users
routes/users/POST.tsPOST /users
routes/users/[id]/GET.tsGET /users/:id
routes/users/[id]/DELETE.tsDELETE /users/:id
routes/users/[id]/posts/[postId]/GET.tsGET /users/:id/posts/:postId
routes/users/users/[id]/GET/usersPOST/usersGET/users/:idDELETE/users/:id

The rules

  • Only exact GET.ts / POST.ts / PUT.ts / PATCH.ts / DELETE.ts filenames become routes. Everything else in a route folder is invisible to the scanner.
  • Files starting with _ are ignored entirely — use _shared/ or _helpers.ts for 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.ts will always win over routes/users/[id]/GET.ts for 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, at graph.load(), and dynamically imports every matching file.
  • RouteGraph holds the resulting route table — validated config exports, 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.