Files
What you'll learn
- Write a resource as its own Hono app in its own file, with paths relative to where it will be mounted
- Mount sub-apps with app.route(prefix, subApp) and prefix everything with basePath()
- Know the ordering rule: a sub-app's routes are copied when app.route() runs, so register them first
Composing apps
Seven lessons in, the cats API is one file with every route in it, and it is about to grow owners. A file that holds every route of a real service is the file everyone edits at once. Frameworks built on classes answer this with controllers; Hono's docs say plainly not to build those, because a handler pulled out of app.get('/cats/:id', …) loses the type of c.req.param('id'). Hono's unit of composition is instead the app itself: a Hono instance per resource, in its own file, mounted into the main app under a prefix.
A sub-app per resource
// authors.ts
import { Hono } from 'hono';
const app = new Hono();
app.get('/', (c) => c.json('list authors'));
app.post('/', (c) => c.json('create an author', 201));
app.get('/:id', (c) => c.json(`get ${c.req.param('id')}`));
export default app;
The paths are relative: / and /:id, not /authors and /authors/:id. The file does not know where it will live. The main file decides:
// index.ts
import { Hono } from 'hono';
import authors from './authors';
import books from './books';
const app = new Hono();
app.route('/authors', authors);
app.route('/books', books);
export default app;
app.route(prefix, subApp) copies every route of subApp into app with the prefix in front, so GET /authors/42 reaches the sub-app's /:id handler with id = 42. The sub-app keeps its own middleware and its own onError; a sub-app's notFound is ignored, the parent's answers. Middleware registered on the parent above the route() call wraps the sub-app's routes too, which is how one API key guard covers every resource.
Copies, so order matters
Because route() copies routes at the moment it is called, a sub-app must have its routes before it is mounted:
three.get('/hi', (c) => c.text('hi'));
two.route('/three', three);
app.route('/two', two); // GET /two/three/hi → hi
Mount two before two.route('/three', three) and the copy is taken while two is still empty: GET /two/three/hi is a 404, with nothing to say why. With one file per app, the sub-app registers its routes when its module evaluates, before main.ts gets to app.route(), so the natural layout is also the correct order. A related rule: once the first request has been handled, no route can be added to an app at all; Hono builds its matcher on the first request and refuses changes after it.
A base path for everything
An API often lives under one prefix. basePath() returns an app whose every route and every mounted sub-app sits under it:
const app = new Hono().basePath('/api');
app.get('/', (c) => c.text('root')); // GET /api
app.route('/authors', authors); // GET /api/authors
The returned app shares the router with the original, so it can be exported as the app. Paths outside the base path match nothing.
Types across the split
When the main file mounts a sub-app, TypeScript still knows every route, provided the sub-app chains its registrations instead of calling app.get() on separate lines, const app = new Hono().get(…).post(…), and exports its type. That type is what hc<AppType>() from hono/client uses to give a client autocompletion for every path and body of the API: the docs' RPC feature. It costs nothing to write the sub-app that way from the start.
Your task
The data has moved to data.ts, and two empty apps wait in cats.ts and owners.ts.
- In
cats.ts,GET /answers every cat andGET /:idone cat, or404with{ "error": "Cat <id> not found" }. - In
owners.ts,GET /answers every owner andGET /:id/catsthe cats whoseownerIdis the owner's, or404with{ "error": "Owner <id> not found" }. - In
main.ts, put the whole API under/api, keepGET /apianswering{ "name": "cats api", "version": 1 }, and mount the cats app at/catsand the owners app at/owners.
When it fails
GET /api/catsis a 404 whileGET /api/cats/catsworks: the sub-app's paths carry the prefix too. Insidecats.tsthe list is at/.- Every route is a 404 except the root: the sub-apps are imported but never mounted.
app.route('/cats', cats)is what connects them. GET /apiis a 404 butGET /works: the base path is missing.new Hono().basePath('/api')is the app to register on and to export.GET /api/owners/1/catsanswers every cat: the filter comparedcat.ownerIdwith the parameter string.owner.idis already a number; filter with it.
Remember
- One
Honoper resource, paths relative to/,export default app; the main file mounts withapp.route(prefix, subApp). route()copies routes when it runs: register the sub-app's routes first, and never add a route after the first request.basePath('/api')puts everything under a prefix; the returned app is the one to export.- Chain registrations and export the app's type when a client should know the routes.
Stuck? Show a hint
A sub-app's routes start at '/', not at '/cats': the prefix is given to app.route('/cats', cats) in main.ts. new Hono().basePath('/api') gives an app whose every route, and every mounted sub-app, sits under /api. The owner's cats are cats.filter((cat) => cat.ownerId === owner.id).
Press Run tests to start the app. Its log appears here.Graded endpoints
A route registered at / on an app with basePath('/api') answers at /api
The cats sub-app's / route, mounted at /cats under the base path
The sub-app's /:id route: parameters work the same inside a mounted app
The sub-app answers its own 404
A second sub-app, mounted at /owners
A nested resource route in the owners app, reading the shared data
The owners app's own 404
Nothing lives outside /api: Hono's plain 404