Overview
Koa (current major version 3.x) is a small framework: it ships the application object, the ctx context (request plus response helpers) and the middleware pipeline, and nothing else. Routing, body parsing, CORS and logging are separate packages you add. Middleware are async functions that call await next(); code before the call runs on the way down, code after it runs on the way back up (the "onion" model).
Koa 3 requires Node.js 18 or newer, no longer accepts generator-function middleware (koa-convert is gone), and builds ctx.query with URLSearchParams. @koa/router 15 requires Node.js 20 or newer.
Instructions
Install
npm install koa @koa/router @koa/bodyparser @koa/cors koa-logger
npm install -D @types/koa @types/koa-logger @types/koa__cors # TypeScript only
@koa/router ships its own types, so do not install @types/koa__router. Koa itself has no bundled types: add @types/koa. koa-compose comes in as a dependency of Koa; install it explicitly if you import it directly.
Application skeleton
import Koa from "koa";
import Router from "@koa/router";
import { bodyParser } from "@koa/bodyparser"; // named export, not default
import cors from "@koa/cors";
import logger from "koa-logger";
const app = new Koa();
const router = new Router({ prefix: "/api" });
// 1. Error handler goes first so it wraps everything below it
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
ctx.status = err.status || 500;
ctx.body = { error: err.expose ? err.message : "Internal Server Error" };
ctx.app.emit("error", err, ctx); // still reaches app.on("error")
}
});
app.use(logger());
app.use(cors());
app.use(bodyParser()); // JSON and urlencoded by default
router.post("/orders", async (ctx) => {
const { customerId, items } = ctx.request.body;
if (!customerId || !Array.isArray(items)) {
ctx.throw(400, "customerId and items are required");
}
ctx.status = 201;
ctx.body = { id: "ord_1042", customerId, itemCount: items.length };
});
router.get("/orders/:id", async (ctx) => {
ctx.assert(ctx.params.id.startsWith("ord_"), 404, "Order not found");
ctx.body = { id: ctx.params.id };
});
app.use(router.routes()).use(router.allowedMethods()); // 405/OPTIONS handling
app.on("error", (err) => console.error("unhandled", err));
app.listen(3000, () => console.log("listening on :3000"));
Context essentials
ctx.request.body— parsed body (set by the body parser;{}when nothing was parsed). Parsing only runs for POST, PUT and PATCH.ctx.body = value— object or array becomes JSON, string becomes text/HTML, a stream is piped. Setting a body on a status-less response sets 200.ctx.status,ctx.set(name, value),ctx.get(name),ctx.query,ctx.params(router),ctx.state(per-request scratch space shared by middleware).ctx.throw(status, message)creates an HTTP error; with 4xx statuses the message is exposed to the client (err.expose), with 5xx it is not.ctx.assert(condition, status, message)is the one-line form.- Koa 3 changed the signature:
ctx.throw(status, error, properties)— pass anErrorrather than relying on string-only forms. ctx.back()replacesctx.redirect("back")in Koa 3.- Set
app.proxy = trueonly behind a trusted reverse proxy, soctx.ipandctx.protocolhonourX-Forwarded-*.
Routing with @koa/router 15
- Paths use path-to-regexp 8 syntax. Regex inside parameters (
/:id(\\d+)) no longer works and throws at startup; validate in middleware,router.param()orcreateParameterValidationMiddleware. - Optional segment:
/users{/:id}. Wildcard capture:/files/*pathgivesctx.params.path. - Nest routers:
apiRouter.use("/users", usersRouter.routes()). router.use(mw)registers middleware for the routes of that router only.
Middleware composition
import compose from "koa-compose";
const requireAdmin = compose([requireAuth, requireRole("admin"), auditLog]);
router.delete("/users/:id", requireAdmin, deleteUser);
async function requireAuth(ctx, next) {
const token = ctx.get("Authorization").replace(/^Bearer /, "");
if (!token) ctx.throw(401, "Missing bearer token");
ctx.state.user = await verifyToken(token); // your own verification
await next();
}
Multipart uploads and other body types
@koa/bodyparser does not parse multipart. Use @koa/multer (or busboy) for file uploads. Other types are opt-in: bodyParser({ enableTypes: ["json", "form", "text"], jsonLimit: "2mb" }). Defaults: jsonLimit and textLimit 1mb, formLimit 56kb.
Examples
Example 1: Add a health endpoint and response-time header
User request: "Add a /health route to my Koa app and a header with how long each request took."
app.use(async (ctx, next) => {
const start = process.hrtime.bigint();
await next(); // everything downstream runs here
const ms = Number(process.hrtime.bigint() - start) / 1e6;
ctx.set("X-Response-Time", `${ms.toFixed(1)}ms`);
});
router.get("/health", (ctx) => {
ctx.body = { status: "ok", uptimeSeconds: Math.round(process.uptime()) };
});
Check it: curl -i http://localhost:3000/api/health returns 200, a JSON body such as {"status":"ok","uptimeSeconds":12} and an X-Response-Time: 0.4ms header.
Example 2: Migrate an Express handler
User request: "Convert this Express route to Koa: app.post('/login', (req, res, next) => { ... res.status(401).json({error:'bad credentials'}) })."
router.post("/login", async (ctx) => {
const { email, password } = ctx.request.body;
const user = await users.findByEmail(email);
if (!user || !(await verifyPassword(user, password))) {
ctx.throw(401, "bad credentials"); // replaces res.status(401).json(...) and next(err)
}
ctx.body = { token: await issueToken(user) };
});
Result: the request returns 401 {"error":"bad credentials"} through the error-handling middleware shown above; there is no next(err) call, errors propagate as rejected promises.
Guidelines
- Register the error handler first; middleware registered above it are not protected by it.
- Always
await next(). A forgottenawaitmakes downstream work run after the response is sent. - Do not set
ctx.bodyafter the response has been written; usectx.respond = falseonly when you write toctx.resyourself. - Upgrading from Koa 2: rewrite generator middleware as async functions, check code that reads
ctx.queryarrays, and updatectx.throwandredirect("back")calls. - Upgrading
@koa/routerto v13 or newer: remove:param(regex)routes and old*wildcards, and drop@types/koa__router. - Prefer
@koa/bodyparserand@koa/cors(the maintained scoped packages) in new code;koa-bodyparseris the older package. - Koa has no built-in static file serving, validation or sessions: add
koa-static, a schema validator orkoa-sessiondeliberately and pin versions. - Choose another framework when you want batteries included (NestJS) or schema-driven validation and serialization built in (Fastify).