Skip to main content
Technology & EngineeringApi Frameworks269 lines

Koa

Koa: lightweight Node.js framework by the Express team with async/await middleware, context object, and composable architecture

Quick Summary23 lines
You are an expert in building APIs with Koa.

## Key Points

- Always place error handling middleware first (outermost layer of the onion) so it catches errors from all downstream middleware and route handlers.
- Use `ctx.state` for passing data between middleware layers instead of attaching properties directly to `ctx` — this is the convention Koa reserves for application-level state.
- Compose related middleware with `koa-compose` to create reusable middleware stacks that can be applied as a single unit to routes or routers.
- Forgetting `await next()` in middleware, which silently stops the request from reaching downstream handlers and often results in empty 404 responses.
- Using `ctx.body` instead of `ctx.request.body` to read the parsed request payload — `ctx.body` is the response body setter, and reading from it returns whatever you previously set as the response.

## Quick Example

```bash
npm init -y
npm install koa @koa/router koa-bodyparser
npm install -D typescript @types/koa @types/koa__router @types/koa-bodyparser tsx
```

```bash
npm install zod
```
skilldb get api-frameworks-skills/KoaFull skill: 269 lines
Paste into your CLAUDE.md or agent config

Koa — API Framework

You are an expert in building APIs with Koa.

Core Philosophy

Overview

Koa is a minimal and expressive Node.js web framework created by the team behind Express. It leverages async/await natively, replacing Express's callback-based middleware with a cleaner "onion model" where each middleware can act on both the request and the response. Koa itself ships with no middleware bundled — routing, body parsing, and other features are added via the ecosystem — giving developers full control over their stack.

Setup & Configuration

npm init -y
npm install koa @koa/router koa-bodyparser
npm install -D typescript @types/koa @types/koa__router @types/koa-bodyparser tsx

Basic server (src/server.ts):

import Koa from 'koa';
import Router from '@koa/router';
import bodyParser from 'koa-bodyparser';

const app = new Koa();
const router = new Router();

// Global middleware
app.use(bodyParser());

// Routes
router.get('/', (ctx) => {
  ctx.body = { status: 'ok' };
});

app.use(router.routes());
app.use(router.allowedMethods());

app.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});

Core Patterns

Routing with @koa/router

import Router from '@koa/router';

const router = new Router({ prefix: '/api/v1' });

router.get('/users', async (ctx) => {
  ctx.body = await db.getUsers();
});

router.get('/users/:id', async (ctx) => {
  const user = await db.getUserById(ctx.params.id);
  if (!user) {
    ctx.throw(404, 'User not found');
  }
  ctx.body = user;
});

router.post('/users', async (ctx) => {
  const { name, email } = ctx.request.body as { name: string; email: string };
  const user = await db.createUser({ name, email });
  ctx.status = 201;
  ctx.body = user;
});

router.put('/users/:id', async (ctx) => {
  const user = await db.updateUser(ctx.params.id, ctx.request.body);
  ctx.body = user;
});

router.delete('/users/:id', async (ctx) => {
  await db.deleteUser(ctx.params.id);
  ctx.status = 204;
});

Nested routers for modular route organization:

const usersRouter = new Router({ prefix: '/users' });
const postsRouter = new Router({ prefix: '/posts' });

// Mount sub-routers under the main API router
const apiRouter = new Router({ prefix: '/api/v1' });
apiRouter.use(usersRouter.routes(), usersRouter.allowedMethods());
apiRouter.use(postsRouter.routes(), postsRouter.allowedMethods());

app.use(apiRouter.routes());
app.use(apiRouter.allowedMethods());

The Onion Middleware Model

Koa middleware uses the "onion" pattern — each middleware can execute code before and after await next(), wrapping downstream middleware.

// Logging middleware — runs code before AND after downstream
app.use(async (ctx, next) => {
  const start = Date.now();
  await next(); // pass control downstream
  const ms = Date.now() - start;
  ctx.set('X-Response-Time', `${ms}ms`);
  console.log(`${ctx.method} ${ctx.url} ${ctx.status} - ${ms}ms`);
});

// Error handling middleware — wraps all downstream in try/catch
app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err: any) {
    ctx.status = err.status || 500;
    ctx.body = {
      error: err.message,
      ...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
    };
    ctx.app.emit('error', err, ctx);
  }
});

Validation Middleware

Using a lightweight validation approach with Zod:

npm install zod
import { z, ZodSchema } from 'zod';
import { Middleware } from 'koa';

function validate(schema: { body?: ZodSchema; query?: ZodSchema; params?: ZodSchema }): Middleware {
  return async (ctx, next) => {
    if (schema.body) {
      const result = schema.body.safeParse(ctx.request.body);
      if (!result.success) {
        ctx.throw(400, JSON.stringify(result.error.flatten()));
      }
      ctx.request.body = result.data;
    }
    if (schema.query) {
      const result = schema.query.safeParse(ctx.query);
      if (!result.success) {
        ctx.throw(400, JSON.stringify(result.error.flatten()));
      }
    }
    await next();
  };
}

// Usage
const createUserSchema = {
  body: z.object({
    name: z.string().min(1),
    email: z.string().email(),
    age: z.number().int().min(0).optional(),
  }),
};

router.post('/users', validate(createUserSchema), async (ctx) => {
  const { name, email, age } = ctx.request.body;
  // Body is validated and typed
  ctx.status = 201;
  ctx.body = await db.createUser({ name, email, age });
});

Authentication Middleware

import { Middleware } from 'koa';

function authenticate(): Middleware {
  return async (ctx, next) => {
    const token = ctx.get('Authorization')?.replace('Bearer ', '');
    if (!token) {
      ctx.throw(401, 'Authentication required');
    }

    try {
      const user = await verifyJWT(token);
      ctx.state.user = user; // ctx.state is the recommended namespace for passing data
      await next();
    } catch {
      ctx.throw(401, 'Invalid token');
    }
  };
}

// Apply to specific routes
router.get('/profile', authenticate(), async (ctx) => {
  ctx.body = ctx.state.user;
});

// Or apply to an entire router
const protectedRouter = new Router();
protectedRouter.use(authenticate());
protectedRouter.get('/dashboard', async (ctx) => {
  ctx.body = { user: ctx.state.user };
});

Context (ctx) Essentials

The Koa context encapsulates the request and response into a single object with convenience accessors.

app.use(async (ctx) => {
  // Request
  ctx.method;                    // HTTP method
  ctx.url;                       // Full URL
  ctx.path;                      // URL path
  ctx.query;                     // Parsed query string object
  ctx.params;                    // Route params (via @koa/router)
  ctx.request.body;              // Parsed body (via koa-bodyparser)
  ctx.get('Content-Type');       // Request header
  ctx.ip;                        // Client IP

  // Response
  ctx.status = 200;              // Set status code
  ctx.body = { data: 'value' };  // Set response body (auto-sets Content-Type)
  ctx.set('X-Custom', 'value');  // Set response header
  ctx.type = 'json';             // Set Content-Type shorthand

  // Shared state between middleware
  ctx.state.user = { id: 1 };   // Convention for middleware-to-handler data passing

  // Errors
  ctx.throw(404, 'Not found');   // Throw HTTP error
  ctx.assert(ctx.state.user, 401, 'Login required'); // Assert or throw
});

Best Practices

  • Always place error handling middleware first (outermost layer of the onion) so it catches errors from all downstream middleware and route handlers.
  • Use ctx.state for passing data between middleware layers instead of attaching properties directly to ctx — this is the convention Koa reserves for application-level state.
  • Compose related middleware with koa-compose to create reusable middleware stacks that can be applied as a single unit to routes or routers.

Common Pitfalls

  • Forgetting await next() in middleware, which silently stops the request from reaching downstream handlers and often results in empty 404 responses.
  • Using ctx.body instead of ctx.request.body to read the parsed request payload — ctx.body is the response body setter, and reading from it returns whatever you previously set as the response.

Anti-Patterns

Over-engineering for hypothetical requirements. Building for scenarios that may never materialize adds complexity without value. Solve the problem in front of you first.

Ignoring the existing ecosystem. Reinventing functionality that mature libraries already provide wastes time and introduces risk.

Premature abstraction. Creating elaborate frameworks before having enough concrete cases to know what the abstraction should look like produces the wrong abstraction.

Neglecting error handling at system boundaries. Internal code can trust its inputs, but boundaries with external systems require defensive validation.

Skipping documentation. What is obvious to you today will not be obvious to your colleague next month or to you next year.

Install this skill directly: skilldb add api-frameworks-skills

Get CLI access →