zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Server framework

Route CompositionSupported

There is no RouterModule and no forRoutes configuration. A route's path is the controller prefix plus the method path, composed once by the decorators — so prefixing is a string, not a registration API.

How paths compose#

@Controller('/posts')
export class PostsController {
  @Get('/') list() {} // GET /posts
  @Get('/:id') byId() {} // GET /posts/:id
  @Post() create() {} // POST /posts
}

@Controller(prefix) and the method path are joined, duplicate slashes collapse, and a trailing slash is stripped. @Controller() with no prefix and @Get() with no path both work:

@Controller()
class HealthController {
  @Get('/health') health() {} // GET /health
  @Get() root() {} // GET /
}

Versioning can expand a prefix#

@Version('1', '2')
@Controller('/posts')
export class PostsController {
  /* … */
}

const router = createRouter({ versioning: { kind: 'path', prefix: 'v' } });
router.register(new PostsController());

This registers /v1/posts and /v2/posts at startup. Header and media-type strategies use the same @Version declaration with a different createRouter({ versioning }) option; see API Versioning.

Manual prefixes remain valid, including when a shared constant keeps them consistent:

const V1 = '/api/v1';

@Controller(`${V1}/posts`)
export class PostsController {}

Mounting under a base path#

The application does not know it is mounted, and nothing strips a prefix. So if your platform routes /api/* to the app, your controllers must include /api:

@Controller('/api/posts')
export class PostsController {}

Alternatively strip it in the adapter, which keeps the controllers clean and is usually better:

const out = await app.handle({ ...req, path: req.path.replace(/^\/api/, '') || '/' });

Pick one and be consistent — doing both gives you /api/api/posts, which produces a 404 and no clue.

Route order matters#

Routes are scanned in registration order and the first match wins. There is no specificity ranking:

@Controller('/posts')
class C {
  @Get('/:id') byId() {}
  @Get('/latest') latest() {} // unreachable
}

/posts/latest matches /:id first, with params.id === 'latest'. Declare literal paths before parameterised ones, within a controller and across controllers — the order across controllers is the order of the controllers array in @Module.

This is the single most common routing bug in this framework. See Request Lifecycle.

Grouping by module#

Modules organise providers and controllers; they do not scope paths:

@Module({ controllers: [PostsController, CommentsController] })
export class BlogModule {}

@Module({ controllers: [InvoicesController] })
export class BillingModule {}

@Module({ imports: [BlogModule, BillingModule] })
export class AppModule {}

There is no @Module({ prefix: '/blog' }). The prefix lives on each @Controller, which means it is visible where the routes are and there is no second place to look.

Splitting a large surface#

Two applications, chosen in the adapter, when the split is genuinely separate — different auth, different exposure, different lifecycle:

const publicApp = createApp(PublicModule);
const adminApp = createApp(AdminModule);
await Promise.all([publicApp.init(), adminApp.init()]);

const handler = (req: WebRequest) => (req.path.startsWith('/admin') ? adminApp.handle(req) : publicApp.handle(req));

Nothing is global, so this works. Two containers means shared providers are built twice — keep the pool and driver in a module-scope value both import. See Multiple Servers.

Path parameters#

extractParams matches :name segments and yields strings:

@Get('/:id/comments/:commentId')
async comment(ctx: Ctx<{ id: string; commentId: string }>) {
  return this.repo.findById(Number(ctx.params.commentId));
}

PathParams<'/posts/:id'> derives the params type from a path literal if you would rather not restate it. Everything is a string, so coerce and check — Number('abc') is NaN, and NaN is a number that passes a type check and reaches your database as a nonsense parameter.

There are no optional segments, no wildcards, no regex constraints and no catch-all patterns. A /* route is not expressible; handle unmatched paths in the adapter, where the router's 404 arrives.

---

See also: Controllers & Routing · Request Lifecycle · Multiple Servers