Koa.js APIs: Context, Onion-Model Middleware, Routing, Auth and File Uploads
Key takeaways
Koa.js is a next-generation web framework for Node.js designed by the Express team. It uses async/await for cleaner async code and provides a smaller, more expressive foundation.
Introduction
Koa.js is a new web framework designed by the team behind Express. It aims to be a smaller, more expressive, and more robust foundation for web applications and APIs through async functions.
This post starts from the difference that matters most in practice, the single ctx object and async middleware in place of Express’s req/res callbacks, and then builds up a working API: routing, a REST example, JWT authentication, file uploads, database access with Mongoose or Prisma, validation, error handling and testing with supertest.
Express vs Koa
Express (callback-based):
app.get('/users', (req, res, next) => {
User.find((err, users) => {
if (err) return next(err);
res.json(users);
});
});
Koa (async/await):
router.get('/users', async (ctx) => {
ctx.body = await User.find();
});
Key Differences:
- Koa uses
async/await(no callbacks) - Single
ctxobject instead ofreq/res - No built-in routing or middleware (smaller core)
- Better error handling with try/catch
The comparison is a little unfair to modern Express — Express 5 also forwards rejected promises from async handlers to the error handler — but the structural difference remains. In Express, middleware calls next() and forgets about it; there is no built-in way to run code after the rest of the chain has produced a response, so response-time logging or response rewriting relies on hooking res.on('finish') or monkey-patching res.send. In Koa, await next() returns when every downstream middleware has finished, so “before” and “after” logic sit in the same function, and a single try/catch around await next() sees every error thrown below it.
Where Koa Fits
Koa was started by the same people who built Express (including TJ Holowaychuk), and its core is deliberately small: there is no router, body parser or validation in the box, so you pick each piece yourself. Some larger frameworks, such as Alibaba’s Egg.js and ThinkJS, are built on top of Koa and add the conventions Koa leaves out.
That trade-off decides when it is a good fit. Koa works well for a new service whose team is comfortable with async/await and wants to assemble its own middleware stack, since the onion-model await next() flow makes timing, logging and error boundaries easy to write. If you want the largest selection of ready-made middleware, Express is still the safer default; if you want an opinionated structure with modules and dependency injection, a framework like NestJS gives you that out of the box.
Installation
npm install koa @koa/router
Basic Server:
const Koa = require('koa');
const app = new Koa();
app.use(async (ctx) => {
ctx.body = 'Hello World';
});
app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
Context Object
Koa uses a single ctx (context) object:
app.use(async (ctx) => {
// Request
console.log(ctx.method); // GET
console.log(ctx.url); // /users
console.log(ctx.path); // /users
console.log(ctx.query); // { limit: '10' }
console.log(ctx.headers); // { ... }
console.log(ctx.request.body); // Requires koa-bodyparser
// Response
ctx.status = 200;
ctx.body = { message: 'Hello' };
ctx.set('X-Custom', 'value');
// Helpers
ctx.throw(400, 'Bad Request');
ctx.redirect('/new-url');
ctx.assert(ctx.state.user, 401, 'Unauthorized');
});
This block lists the helpers rather than a realistic handler — ctx.throw() throws an HttpError immediately, so nothing after it runs. ctx wraps Node’s raw objects: ctx.request and ctx.response are Koa’s own abstractions, most of their properties are delegated to ctx for convenience (ctx.path is ctx.request.path, ctx.body is ctx.response.body), and ctx.req/ctx.res are the underlying Node objects. Avoid writing to ctx.res directly; Koa sends the response itself after the middleware chain finishes, and bypassing it leads to ERR_HTTP_HEADERS_SENT errors. ctx.state is the recommended place for per-request data shared between middleware, such as the authenticated user.
Setting ctx.body also sets the status and content type for you: an object becomes JSON with status 200, a string becomes text/plain or text/html, a stream is piped, and null produces 204. If no middleware sets a body or status, Koa responds 404 Not Found — which is why a missing await somewhere in the chain shows up as a mysterious 404 (see the FAQ). Errors thrown with ctx.throw(400, ...) are “exposed” by default, meaning their message is safe to send to clients, while unexpected errors default to status 500 and should not leak their message.
Middleware
Basic Middleware
const Koa = require('koa');
const app = new Koa();
// Logger middleware
app.use(async (ctx, next) => {
const start = Date.now();
await next(); // Call next middleware
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});
// Response middleware
app.use(async (ctx) => {
ctx.body = 'Hello World';
});
app.listen(3000);
The “onion” name comes from this flow: a request passes inward through each middleware until one stops calling next(), then control unwinds outward in reverse order. The logger runs its first line, waits while the response middleware sets the body, then computes the elapsed time on the way out. Order of registration is therefore semantic, not cosmetic — a middleware only wraps the ones registered after it. Forgetting await (writing next() alone) breaks the chain in subtle ways: the logger would record nearly zero milliseconds, and errors thrown downstream become unhandled promise rejections instead of reaching the error handler.
Error Handling Middleware
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
ctx.status = err.status || 500;
ctx.body = {
error: err.message
};
ctx.app.emit('error', err, ctx);
}
});
// Error event listener
app.on('error', (err, ctx) => {
console.error('Server error:', err);
});
Koa already has a default error handler: without this middleware, an uncaught error produces a plain-text response with the right status and is emitted as an error event on the app, which logs it to stderr. A custom handler exists to control the response format (JSON instead of text) — and once you catch the error yourself, Koa no longer sees it, which is why this example re-emits it with ctx.app.emit('error', err, ctx) so central logging still works. One refinement worth adding: send err.message only when err.expose is true (client errors created via ctx.throw), and a generic message for 500s, so database error text and stack details never reach users.
Common Middleware
npm install koa-bodyparser koa-logger @koa/cors koa-helmet
const Koa = require('koa');
const bodyParser = require('koa-bodyparser');
const logger = require('koa-logger');
const cors = require('@koa/cors');
const helmet = require('koa-helmet');
const app = new Koa();
app.use(helmet());
app.use(cors());
app.use(logger());
app.use(bodyParser());
koa-bodyparser parses JSON, URL-encoded, and text bodies into ctx.request.body; it deliberately does not handle multipart/form-data, which is what the upload section’s multer is for. It enforces size limits (1 MB for JSON by default), and a larger payload fails with 413 Payload Too Large — raise jsonLimit for endpoints that legitimately accept big documents. The Koa organization also publishes @koa/bodyparser as the newer successor, with a similar API. Order here matters for the reason described above: helmet and cors go first so that even error responses carry security and CORS headers.
Use @koa/cors, the package maintained under the Koa organization. The older koa-cors package is a different, unmaintained module with a different options API, so installing one and requiring the other fails with “Cannot find module”. Called with no options, @koa/cors reflects the request’s Origin header back, which effectively allows any origin; in production pass an explicit origin (a string or a function that checks an allow-list), and set credentials: true only together with a specific origin.
Routing
npm install @koa/router
const Koa = require('koa');
const Router = require('@koa/router');
const app = new Koa();
const router = new Router();
// Basic routes
router.get('/', async (ctx) => {
ctx.body = 'Home';
});
router.get('/users', async (ctx) => {
ctx.body = [{ id: 1, name: 'John' }];
});
router.post('/users', async (ctx) => {
const user = ctx.request.body;
ctx.status = 201;
ctx.body = user;
});
// Route parameters
router.get('/users/:id', async (ctx) => {
ctx.body = { id: ctx.params.id };
});
// Query strings
router.get('/search', async (ctx) => {
const { q, limit = 20 } = ctx.query;
ctx.body = { query: q, limit };
});
// Use router
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(3000);
router.routes() returns a middleware that dispatches matching requests to handlers; allowedMethods() adds correct responses for requests that match a path but not a method — 405 Method Not Allowed with an Allow header, and automatic handling of OPTIONS. Without it, a DELETE /users to a router that only defines GET /users just falls through to 404, which is misleading for API clients. Route parameters and query values are always strings: ctx.params.id is '42', and limit in the search route is the string '20' when provided but the number 20 when defaulted — a small inconsistency that becomes a real bug in comparisons, so parse and validate them explicitly.
Nested Routers
// routes/users.js
const Router = require('@koa/router');
const router = new Router({ prefix: '/users' });
router.get('/', async (ctx) => {
ctx.body = 'Get all users';
});
router.get('/:id', async (ctx) => {
ctx.body = `Get user ${ctx.params.id}`;
});
module.exports = router;
// app.js
const usersRouter = require('./routes/users');
app.use(usersRouter.routes());
Each router needs its own allowedMethods() call too (app.use(usersRouter.allowedMethods())), or mount child routers on a parent with parent.use('/api', usersRouter.routes(), usersRouter.allowedMethods()) and register only the parent on the app. Splitting routes into files like this is the usual structure for a Koa app, since the framework itself imposes none.
REST API Example
const Koa = require('koa');
const Router = require('@koa/router');
const bodyParser = require('koa-bodyparser');
const app = new Koa();
const router = new Router({ prefix: '/api' });
app.use(bodyParser());
let todos = [
{ id: 1, text: 'Learn Koa', done: false },
{ id: 2, text: 'Build API', done: false },
];
// Get all todos
router.get('/todos', async (ctx) => {
ctx.body = todos;
});
// Get single todo
router.get('/todos/:id', async (ctx) => {
const todo = todos.find(t => t.id === parseInt(ctx.params.id));
if (!todo) {
ctx.throw(404, 'Todo not found');
}
ctx.body = todo;
});
// Create todo
router.post('/todos', async (ctx) => {
const { text } = ctx.request.body;
ctx.assert(text, 400, 'Text is required');
const todo = {
id: todos.length + 1,
text,
done: false,
};
todos.push(todo);
ctx.status = 201;
ctx.body = todo;
});
// Update todo
router.put('/todos/:id', async (ctx) => {
const todo = todos.find(t => t.id === parseInt(ctx.params.id));
if (!todo) {
ctx.throw(404, 'Todo not found');
}
Object.assign(todo, ctx.request.body);
ctx.body = todo;
});
// Delete todo
router.delete('/todos/:id', async (ctx) => {
const index = todos.findIndex(t => t.id === parseInt(ctx.params.id));
if (index === -1) {
ctx.throw(404, 'Todo not found');
}
todos.splice(index, 1);
ctx.status = 204;
});
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(3000);
The in-memory array keeps the example self-contained, but three shortcuts in it are common sources of real bugs once a database replaces it. id: todos.length + 1 produces duplicate ids after any deletion (delete todo 1 of 2, and the next todo gets id 2 again); let the database generate ids. Object.assign(todo, ctx.request.body) copies every field the client sends, including id or any property you did not intend to be writable — the mass-assignment problem; pick allowed fields or validate with a schema (section 9). And ctx.throw(404, ...) inside the handlers relies on it throwing: code after it never runs, which is why no return is needed, but a reader unfamiliar with Koa may not realize that. ctx.status = 204 without a body is correct for a delete; Koa strips any body for 204 responses anyway.
Authentication
npm install jsonwebtoken bcryptjs
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const JWT_SECRET = 'your-secret-key';
// Register
router.post('/auth/register', async (ctx) => {
const { email, password } = ctx.request.body;
const hashedPassword = await bcrypt.hash(password, 10);
// Save user to database...
const user = { id: 1, email, password: hashedPassword };
ctx.status = 201;
ctx.body = { message: 'User created' };
});
// Login
router.post('/auth/login', async (ctx) => {
const { email, password } = ctx.request.body;
// Find user in database...
const user = { id: 1, email, password: '$2a$10$...' };
const isValid = await bcrypt.compare(password, user.password);
ctx.assert(isValid, 401, 'Invalid credentials');
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
ctx.body = { token };
});
// Auth middleware
const authenticate = async (ctx, next) => {
const token = ctx.headers.authorization?.replace('Bearer ', '');
ctx.assert(token, 401, 'Unauthorized');
let decoded;
try {
decoded = jwt.verify(token, JWT_SECRET);
} catch (error) {
ctx.throw(401, 'Invalid token');
}
ctx.state.userId = decoded.userId;
await next(); // outside the try, so downstream errors are not reported as 401
};
// Protected route
router.get('/profile', authenticate, async (ctx) => {
ctx.body = { userId: ctx.state.userId };
});
The middleware structure is the part most worth copying, and it contains a Koa-specific trap that an earlier version of this example fell into: putting await next() inside the try block. Because of the onion model, that try then wraps the entire rest of the request, so a database error or a bug in the /profile handler is caught and reported to the client as 401 Invalid token. I have seen that pattern send people debugging authentication for hours when the real failure was elsewhere. Keep only the token verification inside the try, as above.
The rest is placeholder code, and each placeholder has a production counterpart. The secret must come from an environment variable with enough entropy, never a literal in source. Specify the algorithm when verifying (jwt.verify(token, secret, { algorithms: ['HS256'] })) so a token cannot pick its own. A seven-day token cannot be revoked before it expires; shorter-lived access tokens with a refresh flow, or a server-side deny list, are the usual answers. And the login handler should return the same 401 for “no such user” and “wrong password”, with similar timing, so the endpoint cannot be used to discover which emails are registered. bcryptjs is a pure-JavaScript implementation — slower than the native bcrypt package but without a compile step, which is often the right trade in containers.
File Upload
npm install @koa/multer multer
const multer = require('@koa/multer');
const upload = multer({ dest: 'uploads/' });
// Single file
router.post('/upload', upload.single('file'), async (ctx) => {
ctx.body = { file: ctx.file };
});
// Multiple files
router.post('/upload-multiple', upload.array('files', 5), async (ctx) => {
ctx.body = { files: ctx.files };
});
@koa/multer adapts Express’s multer to Koa and puts results on ctx.file/ctx.files (and text fields on ctx.request.body). The field name passed to single('file') must match the form field exactly; a mismatch fails with MulterError: Unexpected field. With dest, files are written under random names without extensions, which is intentional — never trust the client’s filename for the path on disk. Two settings belong in any real configuration: limits: { fileSize: 5 * 1024 * 1024 } so a client cannot fill the disk, and a fileFilter that checks the MIME type (and, for images, ideally the file’s magic bytes, since the MIME type is client-supplied). Returning ctx.file wholesale, as here, also exposes server paths to the client; return an id or URL instead.
Database Integration
With Mongoose
npm install mongoose
const mongoose = require('mongoose');
mongoose.connect('mongodb://localhost/myapp');
const User = mongoose.model('User', {
name: String,
email: String,
});
router.get('/users', async (ctx) => {
ctx.body = await User.find();
});
router.post('/users', async (ctx) => {
const user = new User(ctx.request.body);
await user.save();
ctx.status = 201;
ctx.body = user;
});
With Prisma
npm install @prisma/client
npx prisma init
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
router.get('/users', async (ctx) => {
ctx.body = await prisma.user.findMany();
});
router.post('/users', async (ctx) => {
ctx.body = await prisma.user.create({
data: ctx.request.body
});
});
Both integrations share the same shape because Koa’s async middleware lets database calls be awaited directly — no callbacks, and a rejected query propagates to the error-handling middleware automatically. What Koa does not do is manage connections: create one Mongoose connection or one PrismaClient at startup and reuse it for every request (creating a client per request exhausts the database’s connection limit), and close it during graceful shutdown. For Prisma you also need the CLI as a dev dependency (npm install -D prisma) to run prisma migrate and prisma generate. Passing ctx.request.body straight into create has the same mass-assignment issue as the REST example — a client could set role: 'admin' if the model has such a field — so pass validated data instead, as shown next.
Validation
Koa has no built-in validation, and older Koa-specific validators were written for the generator-based Koa 1 API. A framework-agnostic schema library such as Zod works well with async middleware:
npm install zod
const { z } = require('zod');
const createUser = z.object({
email: z.string().email(),
password: z.string().min(6).max(20),
});
router.post('/users', async (ctx) => {
const result = createUser.safeParse(ctx.request.body);
if (!result.success) {
ctx.status = 400;
ctx.body = { errors: result.error.flatten().fieldErrors };
return;
}
const { email, password } = result.data;
// Create user...
});
Use result.data rather than ctx.request.body after validation: Zod strips unknown keys by default, so fields a client adds on its own never reach your database layer.
Shutting down cleanly behind an orchestrator
const server = app.listen(3000);
process.on('SIGTERM', () => {
console.log('SIGTERM signal received: closing HTTP server');
server.close(() => {
console.log('HTTP server closed');
// Close database connections
process.exit(0);
});
});
server.close() stops accepting new connections but waits for existing ones to finish, and HTTP keep-alive connections can stay open indefinitely, so the callback may never fire. Orchestrators like Kubernetes send SIGTERM and then SIGKILL after a grace period (30 seconds by default), so add a fallback timer that exits anyway, and on Node 18.2+ call server.closeIdleConnections() right after close() to drop idle keep-alive sockets. Close the database pool inside the callback before exiting, so in-flight queries can complete.
Testing
npm install --save-dev jest supertest
const request = require('supertest');
const app = require('./app');
describe('GET /api/users', () => {
it('should return all users', async () => {
const response = await request(app.callback())
.get('/api/users')
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
});
app.callback() returns a plain Node request handler, which supertest wraps in a temporary server on a random port — so tests never collide on port 3000 and need no running server. This only works if app.js exports the Koa app without calling app.listen(); put listen in a separate server.js entry point. Otherwise the require in the test starts a real server, and Jest reports Jest did not exit one second after the test run has completed or EADDRINUSE when test files run in parallel.
Frequently Asked Questions (FAQ)
Q. Why does my Koa route return 404 Not Found even though the handler sets ctx.body?
A. The usual cause is a middleware earlier in the chain that calls next() without await or return. Koa sends the response once the outermost middleware’s promise resolves, so if an upstream middleware doesn’t wait for next(), the response goes out with the default 404 before your async handler assigns ctx.body. Always write await next() as in the logger example. For the same onion-model reason, register the error-handling middleware first, because it can only catch errors thrown by middleware that runs inside its await next().
Related Articles
- Express.js for REST APIs: Routing, Middleware Order, Error Handling and What Changed in Express 5
- Go Web APIs with net/http: Routing, Middleware, Context, JSON and Database Access
- JavaScript Async Debugging Case Study