Claude Code with Koa: Rules for the Middleware Stack
Why Koa without CLAUDE.md ships middleware that breaks the chain
Koa is a minimal Node.js web framework from the team behind Express, built around async functions and a single context object. The appeal is the middleware model: each middleware is an async function that does its work, awaits next() to run everything downstream, and then does more work as the stack unwinds, which makes cross-cutting concerns like timing, logging, and error handling clean to express. The risk is that this model depends on one discipline, awaiting next(), and an assistant trained heavily on Express tends to write Koa middleware with Express reflexes that quietly break the chain.
A Claude Code session that builds a Koa app without project rules produces middleware shaped by Express habits. It calls next() without awaiting it, so the downstream stack runs but the upstream middleware finishes before it, breaking timing and error capture. It reaches for res.send() and res.json(), which do not exist in Koa, instead of setting ctx.body. It writes a try/catch in every handler instead of relying on Koa's centralized error event. It treats ctx.request and ctx.response as if they were Express's req and res with the same methods. Each of these is the difference between middleware that composes correctly and middleware that runs out of order or throws, and the missing await in particular produces bugs that only appear under error conditions or load.
This guide covers the CLAUDE.md configuration that locks Claude Code into Koa patterns that compose correctly: the async middleware contract that requires awaiting next(), the context-object conventions that replace Express's req/res, centralized error handling the Koa way, the body-setting rules, and permission hooks that keep an automated session from breaking the server. For the framework Koa's authors built first, Claude Code with Express covers the patterns Claude defaults to. For a modern typed alternative, Claude Code with Fastify covers the schema-first approach.
The Koa CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Koa project it needs to declare: the version and middleware in use, the async contract, the context conventions, the error-handling model, and the hard rules that block the Express habits Claude carries in.
# Koa rules
## Framework
- Koa v2, @koa/router for routing, koa-bodyparser for bodies
- This is NOT Express: one ctx object, async middleware, await next()
- Middleware is async (ctx, next) => { ... await next(); ... }
## Async middleware (MANDATORY)
- ALWAYS await next() when calling downstream middleware
- A middleware that forgets await next() breaks timing and error capture
- Work before await next() runs on the way in, after runs on unwind
- NEVER call next() without returning or awaiting it
## Context object (MANDATORY)
- Responses set ctx.body, NEVER res.send or res.json (do not exist)
- Status set via ctx.status, NEVER res.status().send()
- Request data from ctx.request.body, ctx.params, ctx.query
- ctx.throw(status, message) for errors, NOT manual status + body
## Error handling (MANDATORY)
- ONE error-handling middleware at the top of the stack, wraps the rest
- Inside it: try { await next() } catch (err) { set ctx.status + ctx.body }
- Handlers use ctx.throw, NEVER a try/catch in every route
- app.on("error", ...) for logging, NOT for the client response
## Hard rules
- NEVER call next() without await (or return)
- NEVER use res.send, res.json, res.status (Express, not Koa)
- NEVER wrap every handler in its own try/catch
- NEVER set ctx.body after awaiting next() unless intentionally post-processing
- NEVER read the raw body without koa-bodyparser configured
- ALWAYS set responses via ctx.body and ctx.status
- ALWAYS use ctx.throw for error responses
- ALWAYS have exactly one top-level error middleware
Three rules here prevent the majority of the breakage Claude generates without them.
The await-next rule is the one that causes the subtlest bugs. Koa's middleware model is an onion: each middleware runs its pre-work, awaits next() to run the entire downstream stack, then runs its post-work as the stack unwinds. If a middleware calls next() without awaiting, the downstream stack still runs, but the current middleware continues immediately instead of waiting, so post-work runs before downstream completes and any error downstream is not caught by an upstream try/catch. Claude omits the await because Express's next() is synchronous and fire-and-forget. The rule makes awaiting next() mandatory, which is what keeps the onion intact.
The context-object rule prevents the methods-that-do-not-exist class of error. Koa has one ctx object, and responses are set by assigning ctx.body and ctx.status, not by calling res.send() or res.json(), which Koa does not provide. Claude reaches for the Express methods constantly because that is the bulk of its Node training data. The rule maps every Express response pattern to its Koa equivalent so Claude sets ctx.body instead.
The centralized-error rule prevents the try/catch sprawl. In Koa, the idiom is one error middleware at the top of the stack that wraps everything in a single try/catch, and handlers signal errors with ctx.throw. Claude, used to Express where every async handler needs its own error handling, writes a try/catch in each route. The rule establishes the single top-level handler and the ctx.throw convention so error handling lives in one place.
Install and project structure
Install Koa and the middleware the app actually uses. A typical API has the router and a body parser at minimum.
# Core framework and router
npm install koa @koa/router
# Body parsing
npm install koa-bodyparser
# Types (TypeScript)
npm install -D @types/koa @types/koa__router
Keep middleware organized so Claude finds the error handler and the shared middleware rather than reinventing them.
src/
app.ts , app setup, middleware order, error handler first
middleware/
errorHandler.ts , the single top-level error middleware
requestId.ts , cross-cutting middleware (await next pattern)
routes/
users.ts , @koa/router routes
lib/
The structure puts the error handler in one place at the top of the stack, where it must be to wrap everything below. The middleware/ directory holds cross-cutting concerns written in the await-next pattern, so Claude has a correct example to follow. The routes/ files use @koa/router and set ctx.body, keeping the response conventions consistent. This is the layout the CLAUDE.md conventions assume.
The async middleware contract
The single most important discipline in a Koa app is awaiting next(). A cross-cutting middleware like request timing shows the full onion: pre-work, await, post-work.
// src/middleware/requestId.ts
import { Context, Next } from "koa";
import { randomUUID } from "node:crypto";
export async function requestTiming(ctx: Context, next: Next) {
const id = randomUUID();
ctx.state.requestId = id;
const start = Date.now();
await next(); // runs the entire downstream stack, then unwinds here
const ms = Date.now() - start;
ctx.set("X-Response-Time", `${ms}ms`);
ctx.state.log?.({ id, ms, status: ctx.status });
}
The structure is the whole point. The pre-work sets a request id and records the start time, await next() runs every downstream middleware and the route handler to completion, and the post-work measures elapsed time and sets a response header. Because next() is awaited, the timing wraps the real request duration and the header is set on the actual response. If the await were missing, the post-work would run immediately, the timer would read near zero, and an error downstream would escape this middleware's scope. This onion behaviour is what the await-next rule protects, and it is the property that makes Koa middleware composable.
The state passed between middleware goes on ctx.state, which is the per-request namespace for application data. Putting the request id there makes it available to every downstream middleware and the handler without a separate mechanism.
Responses with ctx.body, not res.send
Koa sets responses by assigning to ctx.body and ctx.status. The framework infers the content type from the body and serializes objects to JSON automatically.
// src/routes/users.ts
import Router from "@koa/router";
const router = new Router({ prefix: "/users" });
router.get("/:id", async (ctx) => {
const user = await getUser(ctx.params.id);
if (!user) ctx.throw(404, "user not found");
ctx.status = 200;
ctx.body = user; // serialized to JSON, content-type set automatically
});
export default router;
Three Koa conventions are at work. The route reads the id from ctx.params.id, the Koa router's parameter object, not an Express req.params. It signals a missing user with ctx.throw(404, ...), which throws an error that the top-level error middleware catches, rather than manually setting a 404 body. It sets the success response by assigning ctx.status and ctx.body, and Koa serializes the object to JSON and sets the content type. There is no res.json() because Koa does not have one. This is the response model the context-object rule keeps Claude inside, and it is cleaner than the Express equivalent once the habit is in place.
Centralized error handling
Koa's error handling is one middleware at the top of the stack that wraps everything in a try/catch, plus an app.on("error") listener for logging. Handlers throw, the middleware catches and shapes the response.
// src/middleware/errorHandler.ts
import { Context, Next } from "koa";
export async function errorHandler(ctx: Context, next: Next) {
try {
await next();
} catch (err) {
const e = err as { status?: number; message?: string; expose?: boolean };
ctx.status = e.status ?? 500;
ctx.body = {
error: e.expose ? e.message : "internal server error",
requestId: ctx.state.requestId,
};
ctx.app.emit("error", err, ctx); // for logging, not the response
}
}
This single middleware, registered first, wraps the entire downstream stack in one try/catch because of the awaited next(). When any handler calls ctx.throw(404, ...) or any code throws, control unwinds to this catch block, which sets the status and a shaped body. The e.expose flag, which ctx.throw sets for client-safe errors, decides whether to show the real message or a generic one, so internal errors do not leak. The ctx.app.emit("error", ...) sends the error to the logging listener without affecting the client response. This is the centralized model the CLAUDE.md establishes, and it replaces the per-handler try/catch sprawl that Express habits produce.
Wiring the stack in the right order is what makes the error handler effective.
// src/app.ts
import Koa from "koa";
import bodyParser from "koa-bodyparser";
import { errorHandler } from "./middleware/errorHandler";
import { requestTiming } from "./middleware/requestId";
import usersRouter from "./routes/users";
const app = new Koa();
app.use(errorHandler); // FIRST: wraps everything below
app.use(requestTiming); // timing wraps the routes
app.use(bodyParser()); // parse bodies before routes read them
app.use(usersRouter.routes());
app.on("error", (err, ctx) => {
console.error("server error", { err, requestId: ctx.state.requestId });
});
export { app };
The order is load-bearing. The error handler is first so its try/catch wraps every middleware and route below it. The timing middleware comes next so it measures the full request. The body parser runs before the routes so handlers can read ctx.request.body. The app.on("error") listener logs without touching the response. Because middleware order determines the onion layers, this sequence is what the CLAUDE.md captures so Claude registers them correctly.
Reading request bodies
Koa does not parse request bodies by default, so reading ctx.request.body requires the body parser to be registered. With it, the parsed body is available on ctx.request.body.
router.post("/", async (ctx) => {
const { name, email } = ctx.request.body as { name: string; email: string };
if (!name || !email) ctx.throw(400, "name and email required");
const user = await createUser({ name, email });
ctx.status = 201;
ctx.body = user;
});
The handler reads the parsed body from ctx.request.body, which is populated because bodyParser() runs earlier in the stack. It validates the required fields and throws a 400 through ctx.throw when they are missing, which the error middleware turns into a clean response. The success path sets a 201 and the created user as the body. Note that the body lives on ctx.request.body, not ctx.body, which is the response, a distinction Claude conflates without the convention spelled out. For validating these bodies rigorously, Claude Code with Zod covers schema parsing at the boundary.
Permission hooks for Koa operations
A Koa project's risky automated operations are around the build, the process, and dependencies. Permission hooks in .claude/settings.local.json keep the destructive ones out of an automated session.
{
"permissions": {
"allow": [
"Bash(npm run dev*)",
"Bash(npm run build*)",
"Bash(npx tsc --noEmit*)",
"Bash(npm test*)"
],
"deny": [
"Bash(rm -rf node_modules*)",
"Bash(npm install * --force*)",
"Bash(kill -9*)",
"Bash(*.env*)"
]
}
}
The allow list permits the operations a Koa session needs: the dev server, a build, a type-check, and tests. The deny list blocks wiping node_modules, forced installs that could pull a mismatched Koa or middleware version, force-killing processes, and any shell command touching a .env file. Combined with the Claude Code hooks system for project-wide policy, the result is a setup where Claude can build and test the server freely without breaking the dependency tree or exposing environment secrets.
Common Claude Code mistakes with Koa
Six patterns Claude generates incorrectly without CLAUDE.md constraints.
1. next() called without await
Claude generates: next(); then continues immediately.
Correct pattern: await next(); so the onion unwinds in order and errors are caught.
2. res.send or res.json
Claude generates: res.json(user) or res.send(data).
Correct pattern: ctx.body = user, which Koa serializes and sets the content type.
3. try/catch in every handler
Claude generates: a try/catch wrapping each route's body.
Correct pattern: one top-level error middleware; handlers use ctx.throw.
4. ctx.status set via Express chaining
Claude generates: res.status(404).send(...).
Correct pattern: ctx.throw(404, ...) or set ctx.status and ctx.body.
5. Request body read without the parser
Claude generates: reading ctx.request.body with no bodyParser() registered.
Correct pattern: register koa-bodyparser before the routes that read bodies.
6. ctx.body confused with ctx.request.body
Claude generates: reading the request from ctx.body.
Correct pattern: request data from ctx.request.body, response set on ctx.body.
Add each pattern to CLAUDE.md with the corrected form. Combined with CLAUDE.md examples for the general structure, the result is Koa code Claude generates correctly the first time.
Testing the Koa integration
The tests that matter for Koa prove the middleware composes: the error handler catches a thrown error and shapes the response, and the onion order holds. A test that only hits the happy path proves nothing about the chain.
import { describe, it, expect } from "vitest";
import request from "supertest";
import { app } from "../src/app";
describe("koa middleware", () => {
it("shapes a thrown error through the top-level handler", async () => {
const res = await request(app.callback()).get("/users/missing");
expect(res.status).toBe(404);
expect(res.body.error).toBe("user not found");
expect(res.body.requestId).toBeDefined();
});
it("sets a timing header on success", async () => {
const res = await request(app.callback()).get("/users/u_1");
expect(res.headers["x-response-time"]).toMatch(/ms$/);
});
});
The first test confirms a ctx.throw(404) from a handler is caught by the top-level error middleware and turned into a clean JSON body with the request id, which proves the centralized handling and the awaited next() are working. The second confirms the timing middleware set its header, which proves the onion unwound correctly. If a middleware forgot to await next(), the timing header would be missing or wrong and the error body might be empty. For the broader testing setup, Claude Code with Vitest covers the configuration patterns.
Building Koa middleware that composes correctly
The Koa CLAUDE.md in this guide produces an app where every middleware awaits next() so the onion unwinds in order, responses are set through ctx.body and ctx.status, errors are handled by one top-level middleware with handlers using ctx.throw, and request bodies are read from ctx.request.body after the parser runs. The result is a middleware stack that composes the way Koa intends, not one carrying Express habits that break the chain.
The principle is the same as any framework integration with Claude Code where the assistant's default differs from the target. Koa without a CLAUDE.md produces Express-shaped code that compiles and breaks Koa's two defining features: the async onion and the single context object. The CLAUDE.md is what teaches Claude the Koa model and keeps it there.
For the framework Koa's authors built first and that Claude defaults to, Claude Code with Express covers those patterns. For a modern typed alternative with schema validation, Claude Code with Fastify covers the schema-first approach. For another minimal framework with a similar context model, Claude Code with Hono covers the edge-ready patterns.
Get Claudify. Ship Koa middleware that awaits next and uses ctx the way the framework intends.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify