Consistent JSON Error Shape with ctx.throw and a Central Handler
Error Handling
Build a central Koa error-handling middleware that catches every thrown error and returns a consistent JSON error envelope, covering validation, not-found, and unexpected server errors.
What you'll learn
- Use ctx.throw() to throw HTTP errors with a status code and message
- Write a top-of-stack error middleware that serialises errors to JSON
- Distinguish between operational errors (4xx) and programmer errors (5xx)
Without a central error handler, each route must contain its own try/catch
and decide how to format the error response. A single top-level middleware
handles every thrown error in one place and guarantees a consistent JSON shape.
ctx.throw()
ctx.throw(status, message, properties?) throws an error that Koa catches and
turns into an HTTP response. Use it anywhere in a middleware or controller:
export async function getArticle(ctx) {
const article = await ArticleService.findById(ctx.params.id);
if (!article) ctx.throw(404, "Article not found");
ctx.body = article;
} Pass a third argument to attach extra data — validation errors for example:
ctx.throw(422, "Validation failed", {
errors: { title: ["Required"] },
}); Central Error Middleware
Register this middleware first (before the router) so it wraps every subsequent middleware:
// src/middleware/errorHandler.js
export async function errorHandler(ctx, next) {
try {
await next();
} catch (err) {
const status = err.status || err.statusCode || 500;
const expose = status < 500; // safe to show message to client
ctx.status = status;
ctx.body = {
error: {
status,
message: expose ? err.message : "Internal Server Error",
...(err.errors && { errors: err.errors }),
},
};
// re-emit 5xx so app's 'error' listener can log/alert
if (status >= 500) ctx.app.emit("error", err, ctx);
}
} Mount it at the very top of the middleware stack:
import Koa from "koa";
import { errorHandler } from "./middleware/errorHandler.js";
import bodyParser from "koa-bodyparser";
import articlesRouter from "./routes/articles.js";
const app = new Koa();
app.on("error", (err, ctx) => {
console.error("[unhandled]", err); // log or send to Sentry
});
app.use(errorHandler); // ← must be first
app.use(bodyParser());
app.use(articlesRouter.routes());
app.use(articlesRouter.allowedMethods());
app.listen(3000); Consistent Error Shape
All error responses now share the same envelope:
// 404
{ "error": { "status": 404, "message": "Article not found" } }
// 422 with field errors
{ "error": { "status": 422, "message": "Validation failed",
"errors": { "title": ["Required"] } } }
// 500 (message hidden from client)
{ "error": { "status": 500, "message": "Internal Server Error" } } 404 for Unknown Routes
Add a catch-all after all routers to return a proper 404 instead of Koa’s default empty response:
app.use((ctx) => {
ctx.throw(404, `Route ${ctx.method} ${ctx.path} not found`);
}); Up Next
Document your API with an OpenAPI spec so consumers know exactly what to expect.
OpenAPI Documentation →