404 Handling

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.

3 min read Level 1/5 #koa#routing#404
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.

Middleware Model →