Router & Route
TriFrost's Router
and Route
systems are composable, fully-typed, and deeply integrated with middleware, context, and observability.
If you're coming from Express, Fastify, or Hono, you'll feel right at home, and TriFrost adds:
- Type-safe param extraction
- Route-scoped timeouts, middleware, and rate limits
- Lifecycle hooks like onNotFound and onError
- Route metadata for introspection and tracing
Every
App
is a Router, no need to mount them separately.
π See also: App Class | Context Api | Routing Basics | Error & NotFound handlers
π§° Methods Overview
Every Router
(and App
, since it extends Router
) exposes the following methods:
.get(path, ...)
: Register aGET
route.post(path, ...)
: Register aPOST
route.put(path, ...)
:Register aPUT
route.patch(path, ...)
: Register aPATCH
route.del(path, ...)
: Register aDELETE
route.group(path, ...)
: Create a nested router with shared config/middleware.route(path, fn)
: Define multiple verbs for a single path (GET
,POST
, ...).use(fn)
: Attach middleware to all matching routes.limit(n)
: Apply rate limit to the router or group.bodyParser(...)
: Override body parsing behavior for this branch.onNotFound(...)
: Custom handler when no route matches.onError(...)
: Custom handler for errors or 4xx/5xx fallback.health(path, fn)
: Register a GET route withkind: 'health'
All routes and groups support typed state inheritance, scoped middleware, and metadata like
name
,description
,timeout
, andkind
.
OPTIONS & HEAD
get(...)
automatically registers aHEAD
route too.OPTIONS
routes are auto-generated for all paths with multiple methods (e.g.GET
,POST
, etc).- If CORS middleware is detected, itβs auto-injected into the
OPTIONS
handler.
Declaring Routes
TriFrost supports all standard HTTP methods as router methods:
app
.get('/hello', (ctx) => ctx.text('Hi there!'));
.post('/form', async (ctx) => {
const data = ctx.body;
return ctx.json(data);
});
Each method corresponds to a verb: get
, post
, put
, patch
, del
.
You can also define routes with the object form:
app.get('/account/:id', {
name: 'AccountView',
description: 'Account view endpoint, renders the accounts',
timeout: 5000,
fn: async (ctx) => {
const { id } = ctx.state;
return ctx.json({ id });
},
});
The object form is required when using
name
,description
,timeout
, or other metadata.
name
in particular gets used in otel traces to give a more human-friendly name to your handler.
π’ Path Parameters
Dynamic segments use :
notation and are automatically added to ctx.state
, fully typed:
app.get('/users/:userId/posts/:postId', (ctx) => {
const { userId, postId } = ctx.state;
return ctx.json({ userId, postId });
});
Wildcards are supported via *
and are available as ctx.state['*']
:
app.get('/docs/*', (ctx) => {
const path = ctx.state['*']; // e.g. "guide/setup"
return ctx.text(`Requested: ${path}`);
});
Wildcard (
*
) needs to be the last part of your path.
π§± Grouping Routes
Group multiple routes under a common prefix and shared config:
app.group('/api', (r) => {
r
.get('/users', ctx => ctx.text('Users'))
.get('/posts', ctx => ctx.text('Posts'));
});
To attach group-level options (like timeout), use the object form:
app.group('/api', {
timeout: 5000,
fn: (r) => {
r.get('/ping', ctx => ctx.text('pong'));
},
});
All nested routes inherit the groupβs settings unless overridden.
π§± Middleware & State
All routes and groups support .use(...)
middleware:
app.group('/admin/:userId', (r) => {
r
.use(async (ctx) => {
const user = await fetchUser(ctx.state.userId);
if (!user) return ctx.setStatus(404);
return ctx.setState({ user });
})
.get('/dashboard', (ctx) => {
return ctx.text(`Welcome ${ctx.state.user.name}`);
});
});
Each middleware can expand ctx.state
, and downstream handlers will inherit the updated type.
Middleware Execution Order
Middleware runs top-down, and is collected in the following order:
.use(...)
attached on parent router(s).use(...)
inside.route(...)
builder.middleware[]
in the route object (if provided)- The handler itself
You can attach as many
.use(...)
chains as you want β each one extendsctx.state
immutably.
π See Middleware Basics and Context & State Management for more.
π₯ Rate Limiting Per Route
Rate limiting can be applied:
- Globally via
app.limit(...)
- Per group via
router.limit(...)
- Per route via
route.limit(...)
app
.limit(100) // Global
.group('/api', (r) => {
r
.limit(50) // Group
.get('/posts', ctx => ctx.text('Rate-limited'));
r.route('/users', (route) => {
route
.limit(10) // Per route
.get(ctx => ctx.text('user get'))
.post(ctx => ctx.text('user create'));
});
});
Limits are enforced per runtime and adapter β Redis, Memory, DO, etc.
π See: Rate Limiting API
π Not Found & Error Handlers
Catch-all handlers can be attached per router:
app
.get('/user/:id', ctx => ctx.text(ctx.state.id))
.onNotFound(ctx => ctx.text('404 Not Found'))
.onError(ctx => ctx.status(500));
.onNotFound(...)
: runs when no matching route is found.onError(...)
: runs on uncaught exceptions or unfinalized 4xx/5xx responses
You can also .setStatus(...)
in a route to let TriFrost triage which handler should be used (e.g. onNotFound
vs onError
).
𧬠Route Metadata
Each route can include metadata:
name
: for logging, tracing, and debuggingdescription
: optional description for the routekind
: one of'std' | 'health' | 'notfound' | 'options'
timeout
: overrides global timeoutfn
: the actual logic
Take Note: At time of writing the
description
prop is not in use across the ecosystem. Towards the future this might become part of the ecosystem with regards to automatic documentation generation.If
name
is omitted:
- We'll try to infer it from the handler function's name (if named).
- Otherwise, a fallback like"GET_/profile/:id"
is generated.
Health Routes
Health routes (kind: 'health'
) are:
- Excluded from rate limiting
- Excluded from tracing/logging
- Marked as
ctx.kind === 'health'
app.health('/status', (ctx) => ctx.text('ok'));
app.health(...)
androuter.health(...)
are shortcuts for this.
π§© Composite .route(...)
Sometimes you want to define multiple handlers for the same path:
app.route('/session', (route) => {
route
.post(ctx => ctx.text('Create session'))
.del(ctx => ctx.text('Delete session'));
});
This is perfect for grouping verbs under a common resource path.
They can also have their own middleware/limit/...:
app.route('/session', (route) => {
route
.use(...)
.use(...)
.limit(...)
.post(ctx => ctx.text('Create session'))
.del(ctx => ctx.text('Delete session'));
});
π Per-Route Body Parsing
You can override the body parser for:
- A whole router (via
.bodyParser(...)
) - A single route (via object form)
Example:
app.bodyParser({type: 'json'}); // global fallback
app.post('/upload', {
bodyParser: {type: 'stream'},
fn: async (ctx) => {
const stream = ctx.body;
...
}
});
This gives you precise control over how body parsing behaves, useful for binary uploads, streaming, or custom types.
π Advanced Typing
TriFrost supports full type inference out of the box, but in larger codebases it's helpful to lock in types globally for consistency.
You can define shared environment and context types like this:
// types.ts
import type {TriFrostContext, TriFrostRouter} from '@trifrost/core';
// Define your runtime-provided Env shape
export type Env = {
Bindings: { DB: D1Database };
Variables: { version: string };
};
// Typed context with a base state
export type Ctx<S = {}> = TriFrostContext<Env, S>;
// Typed router helper
export type Router<S = {}> = TriFrostRouter<Env, S>;
Use those helpers across route modules:
// routes/user.ts
import type {Router, Ctx} from '~/types';
export function userRoutes <S extends {...}> (r: Router<S>) {
r.get('/me', (ctx: Ctx) => {
return ctx.json({ version: ctx.env.Variables.version });
});
};
This gives you:
- β
Typed
ctx.env
,ctx.state
, etc. - β
Inferred
.use(...)
middleware with automatic state merging - β Shared types across routes, middleware, and handlers
π Read more in: App Class | Routing Basics | Context & State Management | Context API
Best Practices
- β
Use
.group(...)
to organize routes by domain or version (/api/v1
,/admin
, etc.) - β
Prefer the object route form when setting metadata like
name
,timeout
,kind
,description
- β
Use
.route(...)
for verb-grouped endpoints (GET/POST/PUT
) on the same path - β
Attach middleware with
.use(...)
early to propagate typed state - β
Use
.onNotFound(...)
and.onError(...)
for clear fallback behavior - β
Declare
health
routes using.health(...)
orkind: 'health'
to enable probe-safe endpoints