Skip to content

Routing

Routa routing is filesystem-based. The location of a file under src/routes determines its URL, and the file owns every HTTP method for that URL. There is no route registry to maintain and no import order that affects matching.

src/routes/users/route.ts -> /users
src/routes/users/$userId/route.ts -> /users/:userId
src/routes/(admin)/audit/route.ts -> /audit
src/routes/users.$userId.posts.ts -> /users/:userId/posts

Routa walks src/routes, converts each route file’s location into a list of segments, and applies three rules to those segments: $name becomes the dynamic parameter :name, (name) is removed from the URL and recorded as a group, and everything else is used literally. The resulting path is what you pass to createRouteRoot(), and Routa reports a diagnostic when the two disagree.

A folder per segment, with the endpoint in route.ts. This is what the scaffolder generates and what the examples use.

  • Directorysrc/routes/
    • Directoryusers/
      • route.ts /users
      • Directory$userId/
        • route.ts /users/:userId
        • Directoryposts/
          • route.ts /users/:userId/posts

Dots in a filename separate segments. Useful for shallow endpoints where a folder per segment adds more nesting than it is worth.

  • Directorysrc/routes/
    • users.ts /users
    • users.$userId.ts /users/:userId
    • users.$userId.posts.ts /users/:userId/posts

Both styles can be mixed in one project, and a flat file can live inside a directory: src/routes/admin/audit.$eventId.ts serves /admin/audit/:eventId.

A segment beginning with $ becomes a path parameter. $userId produces :userId, which is the key you receive in input.params after parsing:

src/routes/users/$userId/route.ts
import { createRoute, createRouteRoot } from "@routa-ts/core";
import { z } from "zod";
const route = createRouteRoot("/users/:userId");
export default route({
get: createRoute({
input: {
params: z.object({ userId: z.string() }),
},
// ...
}),
});

Declaring params is what makes the value validated and typed. A dynamic segment without a matching schema is still routable, but its value never reaches input.

A folder wrapped in parentheses organizes files without contributing a URL segment. src/routes/(admin)/audit/route.ts serves /audit, not /admin/audit.

  • Directorysrc/routes/
    • Directory(public)/
      • Directorystatus/
        • route.ts /status
    • Directory(internal)/
      • middleware.ts applies to /audit only
      • Directoryaudit/
        • route.ts /audit

Groups are the tool for applying one middleware chain to a set of routes that do not share a URL prefix. The group name is recorded in generated metadata and in routa routes output, so it stays visible even though it is invisible in the URL.

Inside src/routes, three names are never treated as routes:

File Role
middleware.ts Middleware applied to that folder and everything below it
schemas.ts Zod schemas for nearby routes
*.test.ts Tests, ignored by route discovery

Any other .ts file is a route file.

  • One path may be served by exactly one file. Two files resolving to the same path is a ROUTA_DUPLICATE_ROUTE error, which is the usual way a directory-style and flat-style file collide.
  • The path passed to createRouteRoot() uses :name for parameters, while the filesystem uses $name.
  • OPTIONS is never declared. Routa generates it for every known path and derives the Allow header from the methods that path declares.
  • HEAD is implicit for any path with a GET. Declare head explicitly only when it needs its own contract.

Routa v0 has no catch-all or optional segments, no regex constraints in path syntax, and no explicit route priority. Constrain a parameter with its Zod schema instead. Matching is handled by Hono’s router, so static segments take precedence over dynamic ones.