Routing
Defining API routes, controllers, schemas, and top-level wiring with Hono.
How to add API endpoints to Toast — route definitions, controllers, schemas, middleware, and the explicit wiring that connects them to the running app.
For how requests move through the full stack, see Request Lifecycle.
Short version
td generate feature <name>That command scaffolds most of the API layer for a new resource:
- contract stub in
shared/contracts/src/ - repository factory in
apps/api/src/repositories/ - service factory in
apps/api/src/services/ - route folder with
routes.ts,*.controller.ts,schemas.ts, andindex.ts - factory tests
- barrel exports for routes, services, and repositories
What it does not do today:
- add tables to
shared/db/src/schema.ts - generate and apply the migration for you
- wire the feature into the top-level app composition (
container.ts,index.ts,app.ts)
Checklist
- Contract in
shared/contracts/src/ - Database table in
shared/db/src/schema.ts - Migration —
td db generate <name> && td db migrate - Repository factory in
apps/api/src/repositories/ - Service factory in
apps/api/src/services/ - Route folder in
apps/api/src/routes/<resource>/ - Export route from
apps/api/src/routes/index.ts - Wire the new stack in
apps/api/src/container.ts - Create the route instance in
apps/api/src/index.ts - Mount it in
apps/api/src/app.ts - Add tests — 100% coverage required
Route folder shape
Route folders currently look like this:
apps/api/src/routes/newsletters/
├── index.ts # barrel export
├── routes.ts # OpenAPI route definitions + factory
├── newsletters.controller.ts # HTTP handlers
├── schemas.ts # Zod / OpenAPI schemas
└── newsletters-factory.test.tsThe responsibilities are split deliberately:
schemas.ts— request and response schemas*.controller.ts— extract auth context, call services, shape HTTP responsesroutes.ts— declare OpenAPI, middleware, and handlersindex.ts— re-export the public surface for the folder
Example: route factory
// apps/api/src/routes/newsletters/routes.ts
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
import { requirePermission } from '../../middleware/require-permission.js';
import type { ApiEnv } from '../../types/hono.js';
import { createNewsletterController } from './newsletters.controller.js';
import { CreateNewsletterSchema, NewsletterSchema } from './schemas.js';
const listRoute = createRoute({
method: 'get',
path: '/',
tags: ['Newsletters'],
responses: {
200: {
description: 'List newsletters',
content: { 'application/json': { schema: z.array(NewsletterSchema) } },
},
},
});
const createRouteDef = createRoute({
method: 'post',
path: '/',
tags: ['Newsletters'],
middleware: [requirePermission({ content: ['create'] })],
request: {
body: {
content: { 'application/json': { schema: CreateNewsletterSchema } },
},
},
responses: {
201: {
description: 'Created newsletter',
content: { 'application/json': { schema: NewsletterSchema } },
},
},
});
export function createNewsletterRoutes(deps: { newsletterService: NewsletterService }) {
const controller = createNewsletterController(deps.newsletterService);
const routes = new OpenAPIHono<ApiEnv>();
routes.openapi(listRoute, (c) => controller.list(c));
routes.openapi(createRouteDef, (c) => controller.create(c, c.req.valid('json')));
return routes;
}The important part is not the specific newsletter code — it is the shape:
- define routes in
routes.ts - create the controller once per route factory
- use middleware in the route definition, not inside service code
Controller pattern
Controllers are thin glue.
import { getAuthContext } from '../../middleware/index.js';
export function createNewsletterController(newsletterService: NewsletterService) {
return {
async list(c: Context) {
const { siteId } = getAuthContext(c);
return c.json(await newsletterService.listNewsletters(siteId), 200);
},
async create(c: Context, body: CreateNewsletter) {
const { siteId } = getAuthContext(c);
return c.json(await newsletterService.createNewsletter(body, siteId), 201);
},
};
}Controllers should:
- read auth/request context
- call the appropriate service method
- return the HTTP response
Controllers should not contain business logic or database access.
Top-level wiring
Toast keeps the runtime graph explicit. Adding a route requires touching multiple top-level files.
apps/api/src/routes/index.ts
Export the route factory from the barrel:
export { createNewsletterRoutes, type NewsletterRoutesDeps } from './newsletters/index.js';apps/api/src/container.ts
Create the repository and service in buildStacks() (or a dedicated stack helper) and return the service.
apps/api/src/index.ts
Create the route instance and add it to the routes object passed into createApp().
apps/api/src/app.ts
Extend the routes dependency type and mount the route:
app.route('/api/newsletters', deps.routes.newsletters);This is intentionally explicit. The app entrypoints are the source of truth for what actually runs.
Rules to remember
- routes never import repositories directly
- controllers never access the database directly
- middleware belongs in route definitions
- all authenticated resource queries are scoped by
siteId - OpenAPI lives in the route layer, not in service code