Routing
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:
- Static lookup - O(1) Map check for routes without parameters (e.g.
/users,/health) - Trie traversal - walks the trie for routes with
:paramor*wildcard segments - 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.
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.
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
Mapfor 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.queryis accessed - Sync fast path - routes without middleware skip
async/awaitentirely, returning JSON inline with zero Promise allocation - Pipeline compilation happens once at startup, not per-request