Skip to content

Route Contracts

A route contract is a single object that declares what one HTTP method accepts, every outcome it can produce, which middleware runs before it, and where its handler begins. It is the source of truth from which Routa derives types, runtime validation, generated metadata, and OpenAPI.

src/routes/users/route.ts
import { randomUUID } from "node:crypto";
import { createRoute, createRouteRoot } from "@routa-ts/core";
import { CreateUserSchema, EmailConflictSchema, UserSchema } from "./schemas.js";
import * as users from "../../services/users.js";
const route = createRouteRoot("/users");
export default route({
post: createRoute({
input: {
body: CreateUserSchema,
},
responses: {
success: {
status: 201,
schema: UserSchema,
},
emailConflict: {
status: 409,
schema: EmailConflictSchema,
},
},
run: async ({ input, ctx }) => {
const correlationId = randomUUID();
ctx.logger.info("users.create", "Creating a user.", {
correlationId,
});
const created = await users.createUser(input.body);
if (!created) {
ctx.logger.warn("users.create_conflict", "Email is already in use.", {
correlationId,
});
return { type: "emailConflict", data: { message: "Email already in use." } };
}
ctx.logger.info("users.created", "User created.", {
correlationId,
userId: created.id,
});
return { type: "success", data: created };
},
}),
});

createRouteRoot(path) binds a file to a URL path and returns a helper for that path. The helper accepts an object whose keys are HTTP methods, and it rejects any other key. Because the path is a literal, Routa can look up the context type generated for that path and give each method the right ctx type.

createRoute(contract) declares one method. It accepts four fields.

An optional map of request sources to Zod schemas. Each source is parsed independently before the handler runs:

Source Parsed from
params Dynamic path segments
query The URL query string
headers Request headers, lowercased
cookies The Cookie header, URI-decoded
body The JSON request body

Only the sources you declare appear on input, and each one arrives as the schema’s parsed output type. A parse failure returns 400 with a problem document listing the failing paths, and run never executes.

A required map of outcome names to a status and a schema. Names are yours to choose; success, notFound, and emailConflict are conventions, not keywords. Every method must declare at least one outcome with a 2xx status.

An optional ordered array of middleware contracts that run before the handler. Middleware declared here applies to this method only, in addition to anything inherited from middleware.ts files. See Middleware.

The handler. It receives { input, ctx } and returns { type, data }, where type is a key of responses or of any middleware rejects map. Results may contain only those two fields; Routa rejects extra fields so that responses stay describable in OpenAPI.

Every handler receives ctx.logger, Routa’s structural RoutaLogger, regardless of configuration. When logging is disabled, ctx.logger is a complete no-op implementation of the same type, so handler code never needs a configuration guard. Middleware adds its own typed keys to ctx on top of that.

  • Declare get, post, put, patch, delete, and head. Never declare options; Routa generates it from the path’s methods.
  • GET and HEAD contracts cannot declare input.body.
  • Response type names must be unique across the method’s own responses and the rejects of every middleware in its chain.
  • Middleware rejects must use 4xx or 5xx statuses.
  • The route config must be the direct default export of the file. Routa resolves it statically, so indirection through a variable exported later cannot be analyzed.

Routa owns the HTTP boundary: routing, validation, middleware context, typed responses, and OpenAPI. Your application owns services, use cases, domain models, persistence, and business rules. A run body that grows past orchestrating a service call and choosing an outcome is usually holding logic that belongs outside the route.