This document describes how to migrate existing applications from @koa/router v10.x (classic JavaScript version) to the next major release (v15), which corresponds to the current TypeScript + path-to-regexp v8 code in this repository.
The guide is written so you can either:
- Jump directly from v10 → v15, or
- Apply the changes incrementally, following the sections in order.
- 1. Overview
- 2. Quick Checklist
- 3. Runtime & Tooling Changes
- 4. Path Matching Changes (path-to-regexp v8)
- 5. TypeScript & Types Changes
- 6. Behavioral Changes and Best Practices
- 7. Migration Recipes
- 8. Troubleshooting
The jump from v10 → v15 is primarily about:
-
Runtime + tooling:
- Requires Node.js ≥ 20 (see
package.jsonenginesfield). - The codebase is now written in TypeScript, with a modern build pipeline.
- Requires Node.js ≥ 20 (see
-
Routing internals:
- Switched to
path-to-regexpv8 for path matching. - Some legacy patterns (especially custom regex in params) are no longer supported.
- Switched to
-
Types:
- TypeScript types are built-in; you should not install
@types/@koa/routeranymore. - Types are more complete and closer to actual runtime behavior.
- TypeScript types are built-in; you should not install
The public Router API (new Router(), .get(), .post(), .use(), .routes(), .allowedMethods(), .param(), .url(), etc.) is largely compatible, but there are important edge cases to handle.
Required steps (most applications):
-
Runtime / tooling
- Update Node.js to ≥ 20.
- Remove any custom build hacks that depended on the old JS layout if you were importing internal files.
-
Routing changes
- Stop using custom regex capture syntax in route params (e.g.
'/user/:id(\\d+)'). - Replace those with validation in handlers or middleware.
- Review any usage of
strict, trailing slashes, and raw RegExp paths.
- Stop using custom regex capture syntax in route params (e.g.
-
Types / TypeScript
- Remove
@types/@koa/routerfrom dependencies/devDependencies. - Update TS imports to use the new exported types from
@koa/router. - Fix any type errors around
RouterContext,RouterMiddleware, andLayerOptions.
- Remove
Recommended steps:
- Adopt the recipes in
recipes/**(nested routes, API versioning, validation, error handling). - Use new utilities and options in
LayerOptionsfor more predictable routing.
-
Node.js requirement
-
New version requires Node.js ≥ 20:
// package.json "engines": { "node": ">= 20" }
-
If your app is on an older Node, upgrade first before bumping @koa/router.
-
-
Build / TypeScript
-
The library is now built with tsup and authored in TypeScript.
-
Public entrypoints are still:
- CommonJS:
dist/index.js - ESM:
dist/index.mjs - Types:
dist/index.d.ts
- CommonJS:
-
For consumers, the migration is mostly transparent:
-
CommonJS:
const Router = require('@koa/router');
-
ESM / TypeScript:
import Router from '@koa/router';
-
-
Avoid importing internal files (e.g.
@koa/router/lib/router) – these were never public API and may have moved.
-
The new version uses path-to-regexp v8 via a wrapper (src/utils/path-to-regexp-wrapper.ts). Several behaviors differ from older versions used in v10.
-
Older versions (v10):
-
Allowed routes like:
router.get('/user/:id(\\d+)', handler);
-
-
New version (v15):
-
Custom regex patterns in parameters are no longer supported.
-
From the README:
Note: Custom regex patterns in parameters (
:param(regex)) are no longer supported in v15+ due to path-to-regexp v8. Use validation in handlers or middleware instead. -
Helper available (since v15.2): Use
createParameterValidationMiddleware(name, regexp)to keep regex validation while moving it into middleware. The same helper can also be used inline on specific routes.import Router, { createParameterValidationMiddleware } from '@koa/router'; const validateUserId = createParameterValidationMiddleware( 'id', /^[0-9]+$/ ); router.param('id', validateUserId).get('/user/:id', (ctx) => { ctx.body = { id: Number(ctx.params.id) }; });
Inline per-route example (same helper):
import Router, { createParameterValidationMiddleware } from '@koa/router'; router.get( '/user/:id', createParameterValidationMiddleware('id', /^[0-9]+$/), (ctx) => { ctx.body = { id: Number(ctx.params.id) }; } );
-
-
Migration strategy:
-
Before (v10):
router.get('/user/:id(\\d+)', (ctx) => { // id is guaranteed to be numeric ctx.body = { id: Number(ctx.params.id) }; });
-
After (v15) – validate inside handler:
const numericId = /^[0-9]+$/; router.get('/user/:id', (ctx) => { if (!numericId.test(ctx.params.id)) { ctx.status = 400; ctx.body = { error: 'Invalid id' }; return; } ctx.body = { id: Number(ctx.params.id) }; });
-
After (v15) – validate via middleware:
function validateNumericId(paramName) { const numericId = /^[0-9]+$/; return async (ctx, next) => { if (!numericId.test(ctx.params[paramName])) { ctx.status = 400; ctx.body = { error: `Invalid ${paramName}` }; return; } await next(); }; } router.get('/user/:id', validateNumericId('id'), (ctx) => { ctx.body = { id: Number(ctx.params.id) }; });
-
-
The tests in
test/router.test.tsshow this “v15 approach” for UUID validation, which is a good reference.
-
path-to-regexpv8 changed how trailing slashes are controlled. Internally, the router normalizes your options:-
LayerOptionsincludes:type LayerOptions = { sensitive?: boolean; strict?: boolean; trailing?: boolean; end?: boolean; prefix?: string; ignoreCaptures?: boolean; pathAsRegExp?: boolean; };
-
normalizeLayerOptionsToPathToRegexp()convertsstrictandtrailinginto the shape expected by v8.
-
-
Impact:
- If you previously relied on very specific behavior of trailing slashes, verify your routes with tests.
- Where possible, write tests that cover both with and without trailing slash for important routes.
-
The router introduces helper utilities:
hasPathParameters(path, options)determineMiddlewarePath(explicitPath, hasPrefixParameters)
-
LayerOptionsgains:ignoreCaptures– ignore regexp captures for middleware-only routes.pathAsRegExp– treat the path literally as a regular expression.
-
Some internal patterns (like
'{/*rest}'or rawRegExppaths) are handled more explicitly when dealing with prefixes or middleware.
Migration tip:
- If you manually created routes with raw regexes, or rely on special middleware paths, test them carefully after upgrade.
- Prefer string paths with parameters where possible; use middleware for validation and complex patterns.
Important fix: Middleware scoped to a specific path now correctly respects path boundaries.
- Previously (buggy behavior): Middleware on
/accountsmight incorrectly run for/users/:userId/accounts - Now (correct behavior): Middleware on
/accountsonly runs for paths starting with/accounts
Example:
const accountsRouter = new Router({ prefix: '/accounts' });
accountsRouter.use(async (ctx, next) => {
ctx.state.isAccount = true; // Only runs for /accounts/*
return next();
});
const usersRouter = new Router({ prefix: '/users' });
usersRouter.get('/:userId/accounts', async (ctx) => {
// ctx.state.isAccount is correctly undefined
// The /accounts middleware does NOT run here
});Migration tip: If you were accidentally relying on the incorrect behavior, you'll need to explicitly add the middleware to the routes where you want it to run.
-
Types are now shipped with the package:
types:./dist/index.d.tsinpackage.json.
-
Remove
@types/@koa/routerfrom your project:npm uninstall @types/@koa/router # or yarn remove @types/@koa/router -
Import types directly from
@koa/router:import Router, { RouterContext, RouterMiddleware } from '@koa/router';
Key types live in src/types.ts and are exported from the main entry:
-
RouterOptions
type RouterOptions = { exclusive?: boolean; prefix?: string; host?: string | string[] | RegExp; methods?: string[]; routerPath?: string; sensitive?: boolean; strict?: boolean; };
-
LayerOptions (used by individual routes)
type LayerOptions = { name?: string | null; sensitive?: boolean; strict?: boolean; trailing?: boolean; end?: boolean; prefix?: string; ignoreCaptures?: boolean; pathAsRegExp?: boolean; };
-
RouterContext – extended Koa context including router-specific fields.
export type RouterContext< StateT = DefaultState, ContextT = DefaultContext, BodyT = unknown > = ParameterizedContext< StateT, ContextT & RouterParameterContext<StateT, ContextT>, BodyT > & { params: Record<string, string>; // Always defined in route handlers request: { params: Record<string, string> }; // Always defined in route handlers routerPath?: string; routerName?: string; matched?: Layer[]; captures?: string[]; newRouterPath?: string; router: Router<StateT, ContextT>; };
-
RouterMiddleware, RouterParameterMiddleware, HttpMethod etc. are also exported.
Migration tips:
-
Replace older custom type definitions with the exported ones:
// Before (v10, with DefinitelyTyped) import Router from '@koa/router'; import { RouterContext } from '@types/koa__router'; // After (v15) import Router, { RouterContext } from '@koa/router';
-
If you had your own
ContextWithRoutertypes, you can usually replace them with the providedRouterContextor extend it.
The router now provides full type inference out of the box. You no longer need to explicitly type ctx and next in most cases:
Before (manual types required):
import Router, { RouterContext } from '@koa/router';
import type { Next } from 'koa';
router.get('/users/:id', (ctx: RouterContext, next: Next) => {
ctx.params.id; // Required explicit type for ctx
return next();
});After (types are inferred):
import Router from '@koa/router';
router.get('/users/:id', (ctx, next) => {
ctx.params.id; // ✅ Inferred as string
ctx.request.params; // ✅ Inferred as Record<string, string>
ctx.body = { ... }; // ✅ Works
return next(); // ✅ Works
});
// Also works for router.use()
router.use((ctx, next) => {
ctx.state.foo = 'bar'; // ✅ Works
return next(); // ✅ Works
});Key improvements:
| Feature | Before | After |
|---|---|---|
ctx in .get(), .post(), etc. |
Manual type required | ✅ Inferred |
next parameter |
Manual type required | ✅ Inferred |
ctx.params |
Optional (ctx.params?.id) |
✅ Always defined |
ctx.request.params |
Required ! assertion |
✅ Always defined |
router.use() middleware |
Manual types required | ✅ Inferred |
Custom HTTP methods with inference:
const router = new Router({
methods: ['GET', 'POST', 'PURGE'] as const
});
// The purge method is automatically typed!
router.purge('/cache/:key', (ctx) => {
ctx.body = { key: ctx.params.key };
});-
As shown in tests around “v15 approach for custom regex”, validation is now expected to be done:
- Inside handlers, or
- Via middleware using
router.param()or regular middleware functions.
-
Example using middleware:
function validateUUID(paramName: string) { const uuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; return async (ctx: RouterContext, next: () => Promise<unknown>) => { if (!uuidRegex.test(ctx.params[paramName])) { ctx.status = 400; ctx.body = { error: `Invalid ${paramName} format` }; return; } await next(); }; } router.get('/role/:id', validateUUID('id'), (ctx) => { ctx.body = { id: ctx.params.id, valid: true }; });
- The new codebase includes recipes for nested routers and API versioning under
recipes/**. Layerlogic aroundsetPrefix()and_reconfigurePathMatching()is more explicit about:- Prefixes that contain parameters (
/users/:userId). - Raw regexp routes (
pathAsRegExp === true). - Special “rest” patterns like
'{/*rest}'.
- Prefixes that contain parameters (
Migration tip:
- If you used nested routers heavily in v10, compare against the
recipes/nested-routesimplementation and tests. - It’s a good template for production-grade nested routing with the new behavior.
Goal: Upgrade to v15 with minimal code changes, focusing on correctness.
-
Upgrade runtime & dependency:
- Ensure Node ≥ 20.
- Bump
@koa/routerto the new major (v15).
-
Remove custom regex parameters:
-
Search for patterns like
':id(',':slug(', etc. -
Replace:
router.get('/user/:id(\\d+)', handler);
with:
router.get('/user/:id', handlerWithValidation);
-
-
Remove
@types/@koa/router(if present). -
Run your test suite and fix any failures related to:
- Trailing slashes,
- Nested routers,
- Raw regex routes.
-
For any subtle routing differences, compare against the new tests and recipes in this repo.
Goal: Take advantage of first-class TypeScript support.
-
Update imports:
import Router, { RouterContext, RouterMiddleware, LayerOptions, RouterOptions } from '@koa/router';
-
Type your Koa app and context:
interface State { user?: { id: string }; } interface CustomContext { requestId: string; } type AppContext = RouterContext<State, CustomContext>; const router = new Router<State, CustomContext>();
-
Replace any custom context typings with
RouterContext(or interfaces based on it). -
Fix new type errors:
- These often reveal actual runtime assumptions that weren’t enforced before.
-
“Route no longer matches with custom regex in parameter”
- Confirm you’re no longer using
:param(regex)style definitions. - Move regex into validation middleware or handlers.
- Confirm you’re no longer using
-
“Trailing slash routes behave differently”
- Check
strict/trailingusage in yourRouterOptionsor route-levelLayerOptions. - Add explicit tests for
/pathvs/path/.
- Check
-
“TypeScript now reports type errors for router context”
- Update imports to use the new exported types.
- Make sure you’re not mixing types from
@types/@koa/routerwith the new ones.
-
“Something that worked in v10 is now broken but not covered here”
- The new version aims to be mostly backward compatible aside from the documented breaking changes.
- If you hit a case that looks like a regression or undocumented breaking change, open an issue on the GitHub repo with a minimal reproduction.
By following this guide, you should be able to migrate from @koa/router v10.x to @koa/router v15.x in a controlled, testable manner.