Introduction to Koa
Koa is a modern, lightweight web framework for Node.js created by the original team behind Express. It aims to be a smaller, more expressive, and more robust foundation for web applications and APIs. Unlike Express, which relies heavily on Node.js' built-in HTTP module and traditional callback patterns, Koa leverages async functions and a middleware stack that flows in a more intuitive, onion-like structure.
If you're coming from Express, you'll find Koa familiar yet refreshingly different. It strips away the cruft, removes deprecated Node.js patterns, and provides a cleaner, more modular approach to handling HTTP requests and responses. This tutorial will take you from writing your first Koa server to building production-ready applications with advanced middleware patterns, error handling, authentication, and deployment considerations.
What is Koa?
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →At its core, Koa is a middleware framework. It does not bundle any middleware within its core, which means it's completely modular and lightweight out of the box. The framework itself is just a thin layer that provides:
- A context object (ctx) that encapsulates both request and response
- A middleware execution model based on async/await
- Error handling that works naturally with try/catch
- A streamlined API without callbacks
Koa comes in two major versions: Koa v1 used generators and co, while Koa v2 (the current standard) uses async/await natively. Throughout this tutorial, we'll focus on Koa v2+ with Node.js 8.x or higher.
Core Philosophy
The Koa team describes the framework as having a "onion model" for middleware. When a request comes in, it passes through each middleware layer in order, then the response travels back through the same layers in reverse. This allows you to do things like set up request timing, modify responses after downstream middleware runs, or handle errors gracefully.
Why Koa Matters
In a landscape dominated by Express, you might wonder why you should invest time in learning Koa. Here are the compelling reasons:
- Async/await first: Koa was built from the ground up for modern asynchronous JavaScript. No more callback hell or confusing promise chains.
- Cleaner error handling: Errors in middleware can be caught with simple try/catch blocks, eliminating the need for special error-handling middleware with four parameters.
- No monkey-patching: Express modifies Node.js' native req and res objects. Koa uses its own context object, keeping the underlying Node.js objects pristine.
- Smaller footprint: Koa's core is tiny. You only add what you need, resulting in leaner applications.
- Future-proof: As Node.js evolves, Koa's design aligns perfectly with modern JavaScript features and patterns.
Setting Up Your First Koa Application
Let's start from scratch. Create a new directory and initialize a project:
mkdir koa-tutorial
cd koa-tutorial
npm init -y
npm install koa
Now create a file called app.js with the simplest possible Koa server:
const Koa = require('koa');
const app = new Koa();
// Simple middleware that responds to every request
app.use(async ctx => {
ctx.body = 'Hello, Koa!';
});
app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
Run it with node app.js and visit http://localhost:3000. You'll see "Hello, Koa!" in your browser. That's the entire server — no routing, no view engines, just pure HTTP handling.
Understanding the Context Object
The ctx (context) object is the heart of Koa. It combines Node.js' request and response objects into a single, unified interface. Here are its most important properties and methods:
// ctx.request - the Node.js request object (wrapped)
// ctx.response - the Node.js response object (wrapped)
// ctx.req - the raw Node.js request object
// ctx.res - the raw Node.js response object
// ctx.state - recommended namespace for passing data between middleware
// ctx.app - reference to the Koa application instance
// ctx.cookies - cookie getter/setter
// ctx.throw() - throw an HTTP error
// ctx.assert() - assert with HTTP errors
app.use(async ctx => {
// Access request information
const method = ctx.method; // GET, POST, etc.
const url = ctx.url; // full URL path
const query = ctx.query; // parsed query string
const headers = ctx.headers; // request headers
// Set response information
ctx.status = 200; // HTTP status code
ctx.body = { message: 'success' }; // auto-serialized to JSON
ctx.type = 'application/json'; // content type
// Use state for middleware communication
ctx.state.user = { id: 1 };
});
The Middleware Stack and the Onion Model
Middleware in Koa is executed in a stack-like fashion, but with a crucial twist: each middleware can perform actions both before and after passing control to the next middleware. This is the "onion model."
// Middleware 1: Logging
app.use(async (ctx, next) => {
const start = Date.now();
console.log(`→ ${ctx.method} ${ctx.url}`);
await next(); // Pass control to the next middleware
const ms = Date.now() - start;
console.log(`← ${ctx.status} ${ms}ms`);
ctx.set('X-Response-Time', `${ms}ms`);
});
// Middleware 2: Add a request ID
app.use(async (ctx, next) => {
const requestId = Math.random().toString(36).substring(2, 15);
ctx.state.requestId = requestId;
ctx.set('X-Request-Id', requestId);
await next();
});
// Middleware 3: Actual response
app.use(async ctx => {
ctx.body = {
requestId: ctx.state.requestId,
timestamp: Date.now(),
message: 'Hello from the onion!'
};
});
The execution order for a request looks like this:
→ Middleware 1 starts (before next())
→ Middleware 2 starts (before next())
→ Middleware 3 executes (no next() call - it's the innermost layer)
← Middleware 2 finishes (after next())
← Middleware 1 finishes (after next())
This pattern is incredibly powerful. You can measure response times, modify headers after the response is generated, compress responses, or log the final status code — all without the downstream middleware knowing anything about it.
Routing in Koa
Koa doesn't include a router in its core. You have two excellent options: @koa/router (officially maintained) or koa-router (community favorite). Let's use @koa/router:
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 page';
});
router.get('/about', async ctx => {
ctx.body = 'About us';
});
// Route with parameters
router.get('/users/:id', async ctx => {
const userId = ctx.params.id;
ctx.body = `User ID: ${userId}`;
});
// Route with query parameters
router.get('/search', async ctx => {
const { q, page } = ctx.query;
ctx.body = `Searching for "${q}" on page ${page || 1}`;
});
// POST route with JSON body parsing
router.post('/users', async ctx => {
// ctx.request.body is available if you use body parser middleware
const body = ctx.request.body;
ctx.body = { created: true, user: body };
ctx.status = 201;
});
// Nested routes
const adminRouter = new Router({ prefix: '/admin' });
adminRouter.get('/', async ctx => {
ctx.body = 'Admin dashboard';
});
adminRouter.get('/settings', async ctx => {
ctx.body = 'Admin settings';
});
// Apply routers
app.use(router.routes());
app.use(router.allowedMethods()); // Returns 405 for unsupported methods
app.use(adminRouter.routes());
app.use(adminRouter.allowedMethods());
app.listen(3000);
Allowed Methods Explained
The allowedMethods() middleware is important. If a route matches the URL but not the HTTP method (e.g., a POST to a GET-only route), it automatically returns a 405 Method Not Allowed with the proper Allow header. Without it, the request would silently fall through, potentially resulting in a confusing 404.
Body Parsing and Request Data
Koa doesn't parse request bodies out of the box. You need the koa-bodyparser middleware for JSON, form data, and text:
npm install koa-bodyparser
const bodyParser = require('koa-bodyparser');
app.use(bodyParser({
enableTypes: ['json', 'form', 'text'],
jsonLimit: '1mb',
formLimit: '1mb',
textLimit: '1mb'
}));
// Now ctx.request.body is available in your routes
router.post('/api/data', async ctx => {
const body = ctx.request.body;
// Validate and process the body
if (!body.name) {
ctx.throw(400, 'Name is required');
}
ctx.body = { success: true, data: body };
});
For file uploads, use koa-multer or @koa/multer:
npm install @koa/multer multer
const multer = require('@koa/multer');
const upload = multer({ dest: 'uploads/' });
router.post('/upload', upload.single('file'), async ctx => {
const file = ctx.file;
ctx.body = {
filename: file.originalname,
size: file.size,
path: file.path
};
});
Error Handling
Error handling in Koa is remarkably straightforward compared to Express. You use standard try/catch blocks, and Koa provides ctx.throw() for convenience:
// Global error handling middleware (always put this at the top)
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
// Handle the error
ctx.status = err.status || 500;
ctx.body = {
error: err.message,
...(ctx.app.env === 'development' && { stack: err.stack })
};
// Optionally emit the error for logging
ctx.app.emit('error', err, ctx);
}
});
// Using ctx.throw() in your routes
router.get('/users/:id', async ctx => {
const user = await findUser(ctx.params.id);
if (!user) {
ctx.throw(404, 'User not found');
}
ctx.body = user;
});
// You can also throw specific HTTP errors with properties
router.post('/validate', async ctx => {
ctx.assert(ctx.request.body.email, 400, 'Email is required');
ctx.assert(
ctx.request.body.email.includes('@'),
422,
'Invalid email format'
);
ctx.body = { valid: true };
});
Error Event Listening
app.on('error', (err, ctx) => {
// Log errors for monitoring
console.error('Server error:', err.message);
console.error('Request context:', ctx.url);
// Send to your error tracking service
});
The error event is emitted whenever an error bubbles up through the middleware stack. This is your central logging hook — use it for structured logging, alerting, or sending errors to services like Sentry.
Authentication and Sessions
Let's build a JWT-based authentication system. This demonstrates how Koa's middleware composition shines for real-world features:
npm install jsonwebtoken koa-jwt
const jwt = require('jsonwebtoken');
const SECRET = 'your-secret-key-change-in-production';
// Login route - issues a token
router.post('/login', async ctx => {
const { username, password } = ctx.request.body;
// Validate credentials (example - use proper auth in production)
if (username === 'admin' && password === 'secret') {
const token = jwt.sign(
{ sub: username, role: 'admin' },
SECRET,
{ expiresIn: '1h' }
);
ctx.body = { token };
} else {
ctx.throw(401, 'Invalid credentials');
}
});
// Middleware to verify JWT
const authMiddleware = async (ctx, next) => {
const authHeader = ctx.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
ctx.throw(401, 'No token provided');
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, SECRET);
ctx.state.user = decoded;
await next();
} catch (err) {
ctx.throw(401, 'Invalid or expired token');
}
};
// Protected route
router.get('/protected', authMiddleware, async ctx => {
ctx.body = {
message: 'You have access!',
user: ctx.state.user
};
});
// Role-based authorization middleware factory
const requireRole = (role) => async (ctx, next) => {
if (ctx.state.user.role !== role) {
ctx.throw(403, 'Insufficient permissions');
}
await next();
};
// Admin-only route
router.get('/admin/data', authMiddleware, requireRole('admin'), async ctx => {
ctx.body = { secretData: 'Very confidential information' };
});
Input Validation
Validation is critical for production applications. While you can roll your own, using a library like Joi provides robust, declarative validation:
npm install joi
const Joi = require('joi');
// Validation middleware factory
const validate = (schema, property = 'body') => async (ctx, next) => {
const data = ctx.request[property];
const { error, value } = schema.validate(data, {
abortEarly: false,
stripUnknown: true
});
if (error) {
const details = error.details.map(d => ({
field: d.path.join('.'),
message: d.message
}));
ctx.throw(400, JSON.stringify(details));
}
// Replace with validated and sanitized data
ctx.request[property] = value;
await next();
};
// Define schemas
const createUserSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(8).max(128).required(),
name: Joi.string().min(2).max(100).required(),
age: Joi.number().integer().min(0).max(150).optional()
});
// Apply validation
router.post('/users', validate(createUserSchema), async ctx => {
// ctx.request.body is now validated and sanitized
const user = await createUser(ctx.request.body);
ctx.status = 201;
ctx.body = user;
});
Structuring Larger Applications
As your application grows, you need a clear structure. Here's a recommended pattern for medium-to-large Koa applications:
project/
├── app.js # Entry point
├── package.json
├── config/
│ ├── index.js # Configuration loader
│ └── database.js # DB configuration
├── middleware/
│ ├── errorHandler.js # Global error handler
│ ├── logger.js # Request logging
│ ├── auth.js # Authentication middleware
│ └── cors.js # CORS handling
├── routes/
│ ├── index.js # Route aggregator
│ ├── users.js # User routes
│ ├── products.js # Product routes
│ └── admin.js # Admin routes
├── controllers/
│ ├── users.js # User controller logic
│ └── products.js # Product controller logic
├── services/
│ ├── users.js # User business logic
│ └── email.js # Email service
├── models/
│ ├── user.js # User model
│ └── product.js # Product model
└── utils/
├── validation.js # Validation helpers
└── response.js # Response formatting
Here's how the main app.js ties everything together:
const Koa = require('koa');
const bodyParser = require('koa-bodyparser');
const cors = require('@koa/cors');
const helmet = require('koa-helmet');
const { errorHandler, logger } = require('./middleware');
const routes = require('./routes');
const config = require('./config');
const app = new Koa();
// Security and utility middleware (applied globally)
app.use(helmet());
app.use(cors(config.cors));
app.use(bodyParser(config.bodyParser));
app.use(errorHandler);
app.use(logger);
// Routes
app.use(routes.public.routes());
app.use(routes.public.allowedMethods());
app.use(routes.private.routes());
app.use(routes.private.allowedMethods());
// Start server
const server = app.listen(config.port, () => {
console.log(`Server running on port ${config.port} in ${config.env} mode`);
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
server.close(() => {
console.log('Server closed');
process.exit(0);
});
});
Route Aggregator Example
// routes/index.js
const Router = require('@koa/router');
const userRoutes = require('./users');
const productRoutes = require('./products');
const public = new Router();
const private = new Router();
// Mount route modules
public.use('/users', userRoutes.public.routes());
public.use('/products', productRoutes.public.routes());
private.use('/users', userRoutes.private.routes());
private.use('/products', productRoutes.private.routes());
module.exports = { public, private };
// routes/users.js
const Router = require('@koa/router');
const userController = require('../controllers/users');
const { authMiddleware } = require('../middleware/auth');
const public = new Router();
const private = new Router();
public.get('/', userController.list);
public.get('/:id', userController.getById);
public.post('/register', userController.register);
private.put('/:id', authMiddleware, userController.update);
private.delete('/:id', authMiddleware, userController.delete);
module.exports = { public, private };
Database Integration
Koa is database-agnostic. Here's an example with Sequelize (SQL ORM) and MongoDB:
Sequelize Example
npm install sequelize sqlite3
const { Sequelize, DataTypes } = require('sequelize');
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: 'database.sqlite'
});
const User = sequelize.define('User', {
email: { type: DataTypes.STRING, allowNull: false, unique: true },
name: { type: DataTypes.STRING, allowNull: false },
age: { type: DataTypes.INTEGER }
});
// Initialize database
async function initDatabase() {
await sequelize.sync({ force: false }); // Set force:true to reset
console.log('Database synchronized');
}
// In your controller
const userController = {
async list(ctx) {
const users = await User.findAll({
limit: ctx.query.limit || 20,
offset: ctx.query.offset || 0
});
ctx.body = users;
},
async getById(ctx) {
const user = await User.findByPk(ctx.params.id);
if (!user) ctx.throw(404, 'User not found');
ctx.body = user;
},
async create(ctx) {
const user = await User.create(ctx.request.body);
ctx.status = 201;
ctx.body = user;
},
async update(ctx) {
const user = await User.findByPk(ctx.params.id);
if (!user) ctx.throw(404, 'User not found');
await user.update(ctx.request.body);
ctx.body = user;
}
};
Testing Koa Applications
Testing is straightforward because Koa's context object can be created independently of an HTTP server. Use supertest for integration tests:
npm install --save-dev jest supertest
// __tests__/users.test.js
const request = require('supertest');
const Koa = require('koa');
const bodyParser = require('koa-bodyparser');
const router = require('../routes/users');
// Create a test app
function createTestApp() {
const app = new Koa();
app.use(bodyParser());
app.use(router.public.routes());
app.use(router.public.allowedMethods());
return app;
}
describe('Users API', () => {
let app;
beforeEach(() => {
app = createTestApp();
});
test('GET /users returns list', async () => {
const response = await request(app.callback())
.get('/users')
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
test('POST /users/register creates user', async () => {
const response = await request(app.callback())
.post('/users/register')
.send({
email: 'test@example.com',
name: 'Test User',
age: 25
})
.expect(201);
expect(response.body.email).toBe('test@example.com');
});
test('GET /users/:id with invalid id returns 404', async () => {
await request(app.callback())
.get('/users/99999')
.expect(404);
});
});
For unit testing middleware in isolation:
// Testing middleware directly
const { authMiddleware } = require('../middleware/auth');
test('authMiddleware rejects request without token', async () => {
const ctx = {
headers: {},
throw: jest.fn((status, message) => {
throw { status, message };
})
};
const next = jest.fn();
try {
await authMiddleware(ctx, next);
} catch (err) {
expect(ctx.throw).toHaveBeenCalledWith(401, 'No token provided');
expect(next).not.toHaveBeenCalled();
}
});
Production Best Practices
1. Security Headers with Helmet
npm install koa-helmet
const helmet = require('koa-helmet');
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"]
}
}
}));
2. CORS Configuration
npm install @koa/cors
const cors = require('@koa/cors');
app.use(cors({
origin: ctx => {
const allowedOrigins = ['https://myapp.com', 'https://admin.myapp.com'];
if (allowedOrigins.includes(ctx.request.origin)) {
return ctx.request.origin;
}
return false;
},
credentials: true,
maxAge: 86400
}));
3. Rate Limiting
npm install koa-ratelimit
const ratelimit = require('koa-ratelimit');
// In-memory rate limiting (use Redis for distributed apps)
const rateLimitStore = new Map();
app.use(ratelimit({
driver: 'memory',
db: rateLimitStore,
duration: 60000,
max: 100,
id: ctx => ctx.ip
}));
4. Structured Logging
Use a proper logging library instead of console.log in production:
npm install pino koa-pino-logger
const logger = require('koa-pino-logger');
app.use(logger({
level: process.env.LOG_LEVEL || 'info',
useLevelLabels: true,
// Redact sensitive fields
redact: ['req.headers.authorization', 'req.body.password']
}));
5. Graceful Shutdown
let server;
function start() {
server = app.listen(process.env.PORT || 3000);
// Handle termination signals
const signals = ['SIGTERM', 'SIGINT', 'SIGUSR2'];
signals.forEach(signal => {
process.once(signal, async () => {
console.log(`Received ${signal}, shutting down`);
// Stop accepting new connections
server.close(() => {
console.log('HTTP server closed');
});
// Close database connections
await sequelize.close();
// Allow existing requests to complete (up to 10 seconds)
setTimeout(() => {
console.log('Forcing shutdown');
process.exit(1);
}, 10000).unref();
});
});
}
start();
6. Environment-Based Configuration
// config/index.js
const env = process.env.NODE_ENV || 'development';
const configs = {
development: {
port: 3000,
cors: { origin: '*' },
bodyParser: { jsonLimit: '1mb' },
database: { dialect: 'sqlite', storage: 'dev.sqlite' }
},
production: {
port: process.env.PORT || 8080,
cors: { origin: ['https://myapp.com'] },
bodyParser: { jsonLimit: '100kb' },
database: {
dialect: 'postgres',
uri: process.env.DATABASE_URL
}
}
};
module.exports = configs[env];
7. Health Checks and Readiness Probes
// Essential for Kubernetes/Docker deployments
const healthRouter = new Router();
healthRouter.get('/health', async ctx => {
ctx.body = { status: 'healthy', timestamp: Date.now() };
ctx.status = 200;
});
healthRouter.get('/ready', async ctx => {
// Check database connectivity
try {
await sequelize.authenticate();
ctx.body = { status: 'ready' };
ctx.status = 200;
} catch (err) {
ctx.body = { status: 'not ready', reason: 'database unavailable' };
ctx.status = 503;
}
});
app.use(healthRouter.routes());
app.use(healthRouter.allowedMethods());
Advanced Patterns
Composing Multiple Middleware
You can compose middleware into reusable units using koa-compose:
npm install koa-compose
const compose = require('koa-compose');
// Create a set of middleware for a specific feature
const featureMiddleware = compose([
async (ctx, next) => {
// Validation
ctx.assert(ctx.request.body, 400, 'Body required');
await next();
},
async (ctx, next) => {
// Transformation
ctx.request.body.normalized = true;
await next();
},
async (ctx, next) => {
// Processing
ctx.body = { processed: true, data: ctx.request.body };
}
]);
// Apply the composed middleware to specific routes
router.post('/feature', featureMiddleware);
Conditional Middleware
// Middleware that only runs in development
const devOnly = (middleware) => async (ctx, next) => {
if (ctx.app.env === 'development') {
return middleware(ctx, next);
}
return next();
};
// Use it
app.use(devOnly(async (ctx, next) => {
console.log('Dev-only debug info:', ctx.state);
await next();
}));
Streaming Responses
const { Readable } = require('stream');
router.get('/stream', async ctx => {
ctx.type = 'text/plain';
ctx.status = 200;
// Create a readable stream
const stream = new Readable({
read() {}
});
// Set the body to the stream
ctx.body = stream;
// Push data chunks
let count = 0;
const interval = setInterval(() => {
count++;
stream.push(`Chunk ${count}\n`);
if (count >= 10) {
stream.push(null); // End the stream
clearInterval(interval);
}
}, 500);
});
WebSocket Integration
npm install ws
const WebSocket = require('ws');
// Create HTTP server from Koa app
const server = require('http').createServer(app.callback());
// Attach WebSocket server
const wss = new WebSocket.Server({ server });
wss.on('connection', (ws, req) => {
// You can access the request to authenticate
console.log('WebSocket connected');
ws.on('message', (message) => {
console.log('Received:', message);
ws.send(`Echo: ${message}`);
});
ws.send('Welcome to the WebSocket server!');
});
// Start the combined server
server.listen(3000, () => {
console.log('HTTP and WebSocket server on port 3000');
});
Performance Optimization
1. Compression
npm install koa-compress
const compress = require('koa-compress');
app.use(compress({
threshold: 2048, // Minimum size to compress (bytes)
br: true, // Enable Brotli compression (Node 11+)
gzip: true
}));
2. ETag and Conditional Requests
npm install koa-etag
const etag = require('koa-etag');
app.use(etag());
// Now responses automatically get ETag headers
// and handle If-None-Match for 304 responses
3. Caching with Cache-Control
// Cache middleware factory
const cacheFor = (seconds) => async (ctx, next) => {
await next();
if (ctx.status === 200) {
ctx.set('Cache-Control', `public, max-age=${seconds}`);
}
};
router.get('/static-data', cacheFor(3600), async ctx => {
ctx.body = { data: 'This is cached for 1 hour' };
});
Migration from Express
If you're migrating an Express application to Koa, here's a comparison table to help you map concepts:
// Express middleware
app.use((req, res, next) => {
// Do something
next();
});
// Koa equivalent
app.use(async (ctx, next) => {
// Do something
await next();
});
// Express error handling
app.use((err, req, res, next) => {
res.status(err.status || 500).json({ error: err.message });
});
// Koa error handling
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
ctx.status = err.status || 500;
ctx.body = { error: err.message };
}
});
// Express: req.params.id
// Koa: ctx.params.id (with @koa/router)
// Express: res.json({ data })
// Koa: ctx.body = { data }
// Express: res.status(201).send()
// Koa: ctx.status = 201
You can also run Express middleware inside Koa using adapters, but it's recommended to rewrite middleware in Koa's async style for the full benefits.
Common Pitfalls and How to Avoid Them
- Forgetting to call await next(): If you don't call
await next()in a middleware that should pass control downstream, the request will hang. Always call it unless you intentionally want to short-circuit the request. - Setting ctx.body multiple times: Koa checks if
ctx.bodyhas been set and will warn you if you try to set it again. This catches bugs where two middlewares try to respond. - Not handling promise rejections: Always wrap
await next()in try/catch in your top-level middleware, or use a global error handler. Unhandled rejections can crash your server. - Using synchronous middleware: Koa expects middleware to return a promise. If you write synchronous middleware without async, the onion model breaks. Always use
asyncfunctions for middleware that callsnext(). - Overloading the context object: While you can add properties to
ctx, prefer usingctx.statefor passing data between middleware. This namespace is specifically designed for this purpose.