Catch Unmatched Routes and Return a Proper Not-Found Response
404 Handling
When no route matches, Koa sends an empty 404 by default. Add a catch-all middleware after the router to return a meaningful JSON or HTML not-found response.
What you'll learn
- Understand when Koa's router falls through without matching
- Add a catch-all 404 middleware after the router
- Return JSON or HTML based on the request Accept header
When @koa/router does not find a matching route, it calls next()
and the request falls through. If nothing else handles it, Koa
sends a 404 with an empty body — not very helpful. Add a catch-all
middleware after the router to give clients a proper response.
Default Fallthrough
app.use(router.routes()).use(router.allowedMethods());
// If no route matched, Koa sends 404 with body "" The status is correct but the body is empty. API clients expect JSON; browsers expect something readable.
Adding a Catch-All Middleware
Mount a middleware after the router:
app.use(router.routes()).use(router.allowedMethods());
app.use(async (ctx) => {
ctx.status = 404;
ctx.body = { error: "not found", path: ctx.path };
}); This middleware only runs when no earlier middleware has set
ctx.body, so it acts as a true catch-all.
JSON vs HTML Based on Accept Header
app.use(router.routes()).use(router.allowedMethods());
app.use(async (ctx) => {
ctx.status = 404;
if (ctx.accepts("json")) {
ctx.body = { error: "not found", path: ctx.path };
} else {
ctx.type = "html";
ctx.body = `<h1>404 — Page Not Found</h1><p>${ctx.path}</p>`;
}
}); ctx.accepts() checks the Accept request header and returns the
best match. API clients sending Accept: application/json get JSON;
browsers get HTML.
Pre-Router 404 Check
For apps that also serve static files, you may want to detect a
missing route early. One pattern uses a flag on ctx.state:
// Middleware before the router
app.use(async (ctx, next) => {
await next();
if (ctx.status === 404 && !ctx.body) {
ctx.body = { error: "not found", path: ctx.path };
}
});
app.use(router.routes()).use(router.allowedMethods()); Because this wraps all downstream middleware, it fires after the router and any other middleware have had a chance to respond.
Full Wired Example
import Koa from "koa";
import Router from "@koa/router";
const app = new Koa();
const router = new Router();
router.get("/health", async (ctx) => {
ctx.body = { status: "ok" };
});
// Mount router
app.use(router.routes()).use(router.allowedMethods());
// Catch-all 404
app.use(async (ctx) => {
ctx.status = 404;
ctx.body = {
error: "not found",
path: ctx.path,
method: ctx.method,
};
});
app.listen(3000); GET /health returns { status: "ok" }. Any other path returns a
structured 404 JSON body.
End of Chapter
You now have a complete routing toolkit: installing @koa/router,
registering routes for every HTTP method, capturing URL params and
query strings, composing nested routers, prefixing paths, attaching
per-route middleware, issuing redirects, and handling 404s gracefully.