Routing

How MiiaJS matches URLs to handlers - static routes, dynamic parameters, wildcards, and route compilation.

MiiaJS uses a trie-based router with a static route fast path. Routes are defined via decorators on controllers, and the router compiles them into an optimized matching structure before handling the first request.

How matching works

When a request arrives, the router resolves a handler in this order:

  1. Static lookup - O(1) Map check for routes without parameters (e.g. /users, /health)
  2. Trie traversal - walks the trie for routes with :param or * wildcard segments
  3. HEAD fallback - if method is HEAD and no match, retries with GET automatically

Priority

When multiple routes could match a URL, the router picks the most specific one:

static segment  >  :param  >  * wildcard

Given these routes:

@Get('/users/me')      // static
@Get('/users/:id')     // param
@Get('/users/*')       // wildcard

A request to /users/me matches the static route. /users/42 matches the param route. /users/42/posts/1 matches the wildcard.

Path parameters

Dynamic segments prefixed with : capture a single path segment:

@Controller('/users')
class UserController {
  @Get('/:id')
  findOne(ctx: RequestContext) {
    return { id: ctx.params.id }
  }
}

Multiple parameters work across nested segments:

@Get('/:userId/posts/:postId')
getPost(ctx: RequestContext) {
  const { userId, postId } = ctx.params
  return { userId, postId }
}

GET /users/42/posts/7{ userId: '42', postId: '7' }

Parameters are always strings. Parse them in your handler if you need numbers.

Wildcards

A * segment captures the entire remaining path:

app.addRoute('GET', '/files/*', (ctx) => {
  // GET /files/images/logo.png → ctx.params['*'] = 'images/logo.png'
  return { path: ctx.params['*'] }
})

Wildcards are useful for catch-all routes, static file serving, and SPA fallbacks. The captured value is available as ctx.params['*'].

Trailing slashes

MiiaJS normalizes trailing slashes - /users and /users/ match the same route. You don't need to register both.

@Get('/users')
list() { return [] }

// All of these match:
// GET /users
// GET /users/

HEAD requests

HEAD requests automatically fall back to the matching GET route if no explicit HEAD route is defined. The framework returns headers only, without a body - matching HTTP spec behavior.

@Get('/health')
check() {
  return { status: 'ok' }
}

// GET  /health → 200 { "status": "ok" }
// HEAD /health → 200 (headers only, no body)

Manual routes

Use app.addRoute() to register routes without decorators:

const app = new Miia()
  .addRoute('GET', '/health', () => ({ ok: true }))
  .addRoute('GET', '/users/:id', (ctx) => {
    return { id: ctx.params.id }
  })
  .addRoute('POST', '/upload', uploadHandler, { middlewares: [authMiddleware] })
  .register(AppModule)
type Handler = (ctx: RequestContext) => unknown

app.addRoute(method: HttpMethod, path: string, handler: Handler, options?: AddRouteOptions): this

The fourth argument is an options object:

interface AddRouteOptions {
  middlewares?: Middleware[]
  skipGlobalGuards?: boolean
  skippedGuardClasses?: Set<unknown> | null // filled from @SkipGuard for controller routes
  bodyLimit?: number | false
  skipGlobalPrefix?: boolean
}

skipGlobalGuards opts the route out of every guard registered with app.useGuard(), bodyLimit is the per-route form of @BodyLimit(), and skipGlobalPrefix is covered in Routes outside the prefix.

Manual routes participate in the pipeline like any other: global middleware from app.use() wraps them, global guards apply to them, and anything in middlewares runs as route-bound middleware inside the global onion.

Global prefix

Mount the whole application under a path segment with the globalPrefix option:

const app = new Miia({ globalPrefix: '/api' })
  .addRoute('GET', '/health', () => ({ ok: true }))
  .register(AppModule)

// GET /api/health
// GET /api/users    (from @Controller('/users'))

The prefix is prepended to everything registered through the router: controller routes, app.addRoute(), and the wildcard route that serveStatic() installs. 'api', '/api', and '/api/' are equivalent - the value is normalized before it reaches the route table.

A prefix containing *, :, ?, #, or whitespace throws a TypeError. A * would collapse the entire route table onto one wildcard slot, and a : would inject a parameter segment into every path in the app.

The prefix has to exist before the routes

MiiaJS resolves routes eagerly - register() and addRoute() write the final path into the route table on the spot instead of deferring resolution to listen(). A prefix assigned after that point would silently apply to later routes only, so it throws instead. The constructor option is the only form that cannot get the ordering wrong.

This is the opposite contract to Nest's similarly named setGlobalPrefix(), which is read when the server starts and can therefore be called at any point during bootstrap.

A prefix that varies per environment is still a constructor argument:

const app = new Miia({ globalPrefix: process.env.API_PREFIX })

The prefix is a deployment detail read at construction time, so it does not need to travel through the container.

Tests are the one place where it is not a constructor argument. A TestApp is assembled through a builder rather than constructed directly, so @miiajs/testing exposes setGlobalPrefix() as a builder step:

const app = await TestApp.create(AppModule).setGlobalPrefix('/api').compile()

The ordering rule is unchanged - compile() is where modules are registered, so the call has to come before it.

The prefix stays out of the OpenAPI spec

globalPrefix is applied inside the router and never reaches the RESOLVED_PREFIX metadata that @miiajs/swagger reads, so the generated paths stay clean - /users, not /api/users. This is what separates it from @Module({ prefix }), which is part of the resolved controller path and does show up in the spec.

Since the documented paths carry no prefix, write the base URL into servers yourself. Without it, "Try it out" in Swagger UI sends the request to /users, which is a 404 under a global prefix.
SwaggerModule.configure({
  title: 'My API',
  version: '1.0.0',
  servers: [{ url: 'http://localhost:3000/api' }],
})

Swagger's own endpoints are not moved either. The spec stays at its configured path and the UI at uiPath - /docs/json and /docs/ by default, not /api/docs/json.

Routes outside the prefix

Health checks and platform probes usually have to answer at a fixed URL. The options object addRoute() takes carries a skipGlobalPrefix flag for them:

const app = new Miia({ globalPrefix: '/api' })
  .addRoute('GET', '/health', () => ({ ok: true }), { skipGlobalPrefix: true })
  .register(AppModule)

// GET /health      → 200
// GET /api/health  → 404

It is the same object described under Manual routes, so a route can opt out of the prefix and still carry its own middleware or body limit.

serveStatic() forwards the same flag, which is how a SPA stays at the root while the API lives under the prefix:

serveStatic(app, '/', './dist', { fallback: 'index.html', skipGlobalPrefix: true })

Routes registered from inside a provider - after DI is wired up, the way @miiajs/swagger registers its endpoints - go through the injected Router, whose add() takes the same options:

import { Injectable, Router, inject } from '@miiajs/core'

@Injectable()
export class HealthRoute {
  private router = inject(Router)

  onReady() {
    this.router.add('GET', '/health', () => ({ ok: true }), { skipGlobalPrefix: true })
  }
}

See Discovery → Registering routes in onReady for what a route registered this late does and does not receive.

Absolute paths you wrote by hand

The prefix rewrites URLs in the router, not the strings in your code. Redirect targets, links inside an HTML response, and hardcoded fetch() paths keep pointing exactly where you typed them. Both ends of this two-line example move, and only one of them is visible in the source:

// With globalPrefix: '/api':
// the mount moves to /api/static/*, so the '/static' target below is a 404,
serveStatic(app, '/static', './public')
// and the route itself moves too - '/' normalizes to the prefix, so this
// answers at /api, not at /.
app.addRoute('GET', '/', () => Response.redirect('/static', 302))

A path of '/' is not a special case: it resolves to the prefix itself, the same way /health resolves to /api/health. So the redirect is both unreachable at the URL you expected and pointing at the wrong target once you do reach it.

Derive such paths from the same prefix value you passed to the constructor, or move both ends out of the prefix with the escape hatch above - the redirect route and the serveStatic() mount both take skipGlobalPrefix.

Route compilation

Before handling the first request, MiiaJS compiles all routes into optimized pipelines:

Global pipeline

Global middleware registered via app.use() is composed once into a single compiledGlobalPipeline that wraps the entire dispatch, including router.match(). This is how global middleware runs on 404 responses and sees errors thrown by the router - see Middleware → Global middleware.

Per-route pipeline

Each decorator route is compiled into its own pipeline in this order:

Global guards (app.useGuard(), unless @SkipGuard filters them)
  → Controller @UseGuard() guards
  → Controller @Use() middlewares
  → Method @UseGuard() guards
  → Method @Use() middlewares
  → Validation decorators
  → Route handler

The per-route pipeline is nested inside the global pipeline at dispatch time: globalMiddleware → router.match → perRouteMiddleware → handler.

Performance

  • Static routes are stored in a Map for O(1) exact matching - no trie traversal needed
  • URL parsing uses indexOf() instead of regex for fast pathname/query extraction
  • Query params are parsed lazily - only when ctx.query is accessed
  • Sync fast path - routes without middleware skip async/await entirely, returning JSON inline with zero Promise allocation
  • Pipeline compilation happens once at startup, not per-request