InteractiveFrameworks

Composing apps

Split the API into files, one Hono app per resource, mount them with app.route() and put the whole thing under a base path.

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

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.

  1. In cats.ts, GET / answers every cat and GET /:id one cat, or 404 with { "error": "Cat <id> not found" }.
  2. In owners.ts, GET / answers every owner and GET /:id/cats the cats whose ownerId is the owner's, or 404 with { "error": "Owner <id> not found" }.
  3. In main.ts, put the whole API under /api, keep GET /api answering { "name": "cats api", "version": 1 }, and mount the cats app at /cats and the owners app at /owners.

When it fails

  • GET /api/cats is a 404 while GET /api/cats/cats works: the sub-app's paths carry the prefix too. Inside cats.ts the 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 /api is a 404 but GET / works: the base path is missing. new Hono().basePath('/api') is the app to register on and to export.
  • GET /api/owners/1/cats answers every cat: the filter compared cat.ownerId with the parameter string. owner.id is already a number; filter with it.

Remember

  • One Hono per resource, paths relative to /, export default app; the main file mounts with app.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).