Building Web APIs with Koa.js
Koa.js is an expressive, minimalist web framework designed by the Express team, which embraces ES2017 async functions as first-class citizens.
It eliminates callback hell and simplifies error handling through its innovative middleware cascading system.
In this hands-on tutorial, you'll create a blog API using Koa.js with MongoDB integration. We'll leverage Koa's unique middleware cascading, context handling, and async/await patterns to build a production-ready API with comprehensive CRUD operations.
Prerequisites
Before starting development, make sure you have:
- Node.js v18.0.0 or higher for full ES2015 and async function support
- MongoDB running locally or cloud database access
- Understanding of JavaScript async/await patterns and REST API principles
Step 1 — Building the Koa.js foundation
In this section, you'll create the basic Koa.js server structure and configure the essential middleware stack that will power your blog API.
Create your project directory and initialize it:
mkdir koa-blog-api && cd koa-blog-api
Set up your Node.js project:
npm init -y
Install Koa.js and the middleware ecosystem:
npm install koa @koa/router @koa/cors koa-bodyparser koa-compress koa-helmet helmet mongoose joi
Here's what each package brings to your application:
koa
: The core framework providing context-based request handling@koa/router
: Official routing middleware with parameter support@koa/cors
: Cross-origin resource sharing configurationkoa-bodyparser
: JSON and form data parsing middlewarekoa-compress
: Response compression for better performancekoa-helmet
: Security headers middlewaremongoose
: MongoDB object modeling for data persistencejoi
: Schema validation for request data integrity
Configure your project for modern JavaScript:
{
"name": "koa-blog-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node app.js",
"dev": "node --watch app.js"
},
...
}
The "type": "module"
enables native ESM support, while --watch
provides automatic server restarts during development.
Create your main application file showcasing Koa's middleware cascading:
import Koa from 'koa';
import Router from '@koa/router';
import cors from '@koa/cors';
import bodyParser from 'koa-bodyparser';
import compress from 'koa-compress';
import helmet from 'koa-helmet';
const app = new Koa();
const router = new Router();
// Security and performance middleware
app.use(helmet());
app.use(compress());
app.use(cors());
// Request parsing middleware
app.use(bodyParser());
// Custom middleware demonstrating Koa's cascading
app.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});
// Root endpoint
router.get('/', (ctx) => {
ctx.body = {
message: 'Welcome to the Koa.js Blog API',
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV || 'development'
};
});
app.use(router.routes());
app.use(router.allowedMethods());
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Koa server running on http://localhost:${PORT}`);
});
This setup demonstrates Koa's middleware composition where each middleware function has the opportunity to perform actions before and after calling next()
.
Launch your development server:
npm run dev
You'll see confirmation that your server is operational:
Koa server running on http://localhost:3000
Test your server in the browser at http://localhost:3000
:
For Postman testing, create a GET request:
You should receive this response structure:
{
"message": "Welcome to the Koa.js Blog API",
"timestamp": "2025-07-22T08:52:06.491Z",
"environment": "development"
}
Your Koa.js foundation is now ready. Notice how the console displays request timing thanks to our custom middleware. Next, we'll integrate MongoDB for data persistence.
Step 2 — Connecting to MongoDB with async patterns
Koa.js excels at handling asynchronous operations. In this step, you'll integrate MongoDB using async/await patterns that align perfectly with Koa's design philosophy.
Create the application structure:
mkdir -p src/{models,controllers,middleware,utils,routes}
Build a database connection manager:
import mongoose from 'mongoose';
class DatabaseManager {
constructor() {
this.connection = null;
}
async connect() {
try {
const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/koa-blog';
this.connection = await mongoose.connect(mongoURI);
console.log('MongoDB connected successfully');
// Handle connection events
mongoose.connection.on('error', (err) => {
console.error('MongoDB connection error:', err);
});
mongoose.connection.on('disconnected', () => {
console.log('MongoDB disconnected');
});
} catch (error) {
console.error('MongoDB connection failed:', error);
process.exit(1);
}
}
async disconnect() {
if (this.connection) {
await mongoose.disconnect();
console.log('MongoDB connection closed');
}
}
getConnection() {
return this.connection;
}
}
export const dbManager = new DatabaseManager();
This connection manager provides robust error handling and connection lifecycle management.
Create a Koa middleware for database health checks:
import mongoose from 'mongoose';
export const databaseHealthCheck = async (ctx, next) => {
if (mongoose.connection.readyState !== 1) {
ctx.status = 503;
ctx.body = {
error: 'Database unavailable',
readyState: mongoose.connection.readyState
};
return;
}
await next();
};
Update your main application file to integrate the database:
import Koa from 'koa';
import Router from '@koa/router';
import cors from '@koa/cors';
import bodyParser from 'koa-bodyparser';
import compress from 'koa-compress';
import helmet from 'koa-helmet';
import mongoose from "mongoose";
import { dbManager } from './src/utils/database.js';
import { databaseHealthCheck } from './src/middleware/database.js';
const app = new Koa();
const router = new Router();
// Security and performance middleware
app.use(helmet());
app.use(compress());
app.use(cors());
app.use(requestId);
// Request parsing middleware
app.use(bodyParser());
// Logging middleware with request ID
app.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
console.log(`[${ctx.requestId}] ${ctx.method} ${ctx.url} - ${ctx.status} - ${ms}ms`);
});
app.use(databaseHealthCheck);
// Enhanced health check
router.get('/health', async (ctx) => {
const dbStatus = mongoose.connection.readyState === 1 ? 'connected' : 'disconnected';
ctx.body = {
status: 'OK',
uptime: process.uptime(),
database: dbStatus,
timestamp: new Date().toISOString()
};
});
router.get('/', (ctx) => {
ctx.body = {
message: 'Welcome to the Koa.js Blog API',
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV || 'development'
};
});
app.use(router.routes());
app.use(router.allowedMethods());
// Graceful shutdown handling
process.on('SIGINT', async () => {
console.log('\nShutting down gracefully...');
await dbManager.disconnect();
process.exit(0);
});
const PORT = process.env.PORT || 3000;
// Connect to database before starting server
await dbManager.connect();
app.listen(PORT, () => {
console.log(`Koa server running on http://localhost:${PORT}`);
});
Restart your server to see the database integration.
npm run dev
You should see both server and database startup messages:
MongoDB connected successfully
Koa server running on http://localhost:3000
Test the enhanced health endpoint:
curl http://localhost:3000/health
The response now includes database status:
{
"status": "OK",
"uptime": 20.048862,
"database": "connected",
"timestamp": "2025-07-22T09:11:24.830Z"
}
Your Koa.js application now has solid MongoDB connectivity with health monitoring. Next, we'll set up the blog post data model.
Step 3 — Designing the blog post model and validation
With the database connected, you will now create a comprehensive blog post model and implement Koa-specific validation middleware that takes advantage of the framework's context system.
Create the blog post Mongoose model:
import mongoose from 'mongoose';
const postSchema = new mongoose.Schema({
title: {
type: String,
required: [true, 'Post title is required'],
trim: true,
maxlength: [200, 'Title cannot exceed 200 characters']
},
content: {
type: String,
required: [true, 'Post content is required'],
trim: true
},
published: {
type: Boolean,
default: false
}
}, {
timestamps: true,
toJSON: {
transform: (doc, ret) => {
delete ret.__v;
return ret;
}
}
});
export const Post = mongoose.model('Post', postSchema);
This model offers automatic slug creation, read time estimation, and excerpt generation—features that improve blog functionality.
Create validation middleware specific to Koa:
import Joi from 'joi';
// Create validation schemas
export const schemas = {
createPost: Joi.object({
title: Joi.string().trim().max(200).required()
.messages({
'string.max': 'Title cannot exceed 200 characters',
'any.required': 'Title is required'
}),
content: Joi.string().trim().required()
.messages({
'any.required': 'Content is required'
}),
published: Joi.boolean().default(false)
}),
updatePost: Joi.object({
title: Joi.string().trim().max(200),
content: Joi.string().trim(),
published: Joi.boolean()
}).min(1),
postParams: Joi.object({
id: Joi.string().hex().length(24).required()
.messages({
'string.hex': 'Invalid post ID format',
'string.length': 'Post ID must be 24 characters long'
})
})
};
// Validation middleware factory
export const validate = (schema, source = 'body') => {
return async (ctx, next) => {
try {
let dataToValidate;
switch (source) {
case 'body':
dataToValidate = ctx.request.body;
break;
case 'params':
dataToValidate = ctx.params;
break;
default:
dataToValidate = ctx.request.body;
}
const { error, value } = schema.validate(dataToValidate, {
abortEarly: false,
stripUnknown: true
});
if (error) {
ctx.status = 400;
ctx.body = {
error: 'Validation Failed',
details: error.details.map(detail => ({
field: detail.path.join('.'),
message: detail.message
}))
};
return;
}
// Store validated data in context
if (source === 'body') {
ctx.request.body = value;
ctx.validatedData = value;
}
await next();
} catch (err) {
ctx.status = 500;
ctx.body = { error: 'Validation processing failed' };
}
};
};
This validation system is designed specifically for Koa's context-based approach, providing detailed error responses and storing validated data in the context.
Create a controller that leverages Koa's context handling:
import { Post } from '../models/Post.js';
import mongoose from 'mongoose';
export class PostController {
static async create(ctx) {
try {
const postData = ctx.validatedData;
const post = new Post(postData);
await post.save();
ctx.status = 201;
ctx.body = {
success: true,
data: post,
message: 'Post created successfully'
};
} catch (error) {
if (error.name === 'ValidationError') {
ctx.status = 400;
ctx.body = {
error: 'Validation failed',
details: Object.values(error.errors).map(err => err.message)
};
} else {
console.error('Create post error:', error);
ctx.status = 500;
ctx.body = { error: 'Failed to create post' };
}
}
}
static async list(ctx) {
try {
const { published } = ctx.query;
const filter = {};
if (published !== undefined) {
filter.published = published === 'true';
}
const posts = await Post.find(filter)
.sort({ createdAt: -1 })
.lean();
ctx.body = {
success: true,
data: posts
};
} catch (error) {
console.error('List posts error:', error);
ctx.status = 500;
ctx.body = { error: 'Failed to retrieve posts' };
}
}
static async getById(ctx) {
try {
const { id } = ctx.params;
const post = await Post.findById(id);
if (!post) {
ctx.status = 404;
ctx.body = { error: 'Post not found' };
return;
}
ctx.body = {
success: true,
data: post
};
} catch (error) {
if (error instanceof mongoose.CastError) {
ctx.status = 400;
ctx.body = { error: 'Invalid post ID format' };
} else {
console.error('Get post error:', error);
ctx.status = 500;
ctx.body = { error: 'Failed to retrieve post' };
}
}
}
}
The controller demonstrates Koa's context-based approach, using ctx.requestId
for request tracking and ctx.validatedData
for accessing validated input.
With your models and validation system in place, you're ready to implement the API endpoints in the next step.
Step 4 — Implementing post creation with Koa routing
Now that you have your model and validation system set up, you'll create the API routes and integrate them with your Koa application. This step highlights Koa's unique approach to middleware composition and context handling that distinguishes it from Express.
Update your main application to integrate the posts routes:
import Koa from "koa";
import Router from "@koa/router";
import cors from "@koa/cors";
import bodyParser from "koa-bodyparser";
import compress from "koa-compress";
import helmet from "koa-helmet";
import mongoose from "mongoose"; // Add this line
import { dbManager } from "./src/utils/database.js";
import { databaseHealthCheck, requestId } from "./src/middleware/database.js";
import postsRouter from './src/routes/posts.js';
...
router.get('/', (ctx) => {
ctx.body = {
message: 'Welcome to the Koa.js Blog API',
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV || 'development'
};
});
// Mount API routes
app.use(postsRouter.routes());
app.use(postsRouter.allowedMethods());
app.use(router.routes());
app.use(router.allowedMethods());
// Graceful shutdown handling
process.on('SIGINT', async () => {
console.log('\nShutting down gracefully...');
await dbManager.disconnect();
process.exit(0);
});
...
The order of middleware in Koa is crucial. Mounting the postsRouter
before the general router ensures API routes have priority. The allowedMethods()
function automatically manages HTTP method validation and returns suitable 405 Method Not Allowed responses—highlighting Koa's thoughtful API design.
Understanding how Koa's validation middleware uses the context system requires exploring the entire validation process. When a POST request reaches /api/posts
, it first passes through the router-level middleware that sets security headers. Then, the validation middleware executes, storing validated data in ctx.validatedData
for the controller to use.
Your PostController's create method demonstrates Koa's emphasis on context-oriented design.
static async create(ctx) {
try {
const postData = ctx.validatedData;
const post = new Post(postData);
await post.save();
ctx.status = 201;
ctx.body = {
success: true,
data: post,
message: 'Post created successfully'
};
} catch (error) {
if (error.name === 'ValidationError') {
ctx.status = 400;
ctx.body = {
error: 'Validation failed',
details: Object.values(error.errors).map(err => err.message)
};
} else {
console.error('Create post error:', error);
ctx.status = 500;
ctx.body = { error: 'Failed to create post' };
}
}
}
Notice how ctx.validatedData
contains the validated request body from the middleware, demonstrating the seamless data flow through Koa's middleware cascade. The unified context eliminates the need to pass multiple objects between functions.
Test your post creation endpoint:
curl -X POST http://localhost:3000/api/posts \
-H "Content-Type: application/json" \
-d '{
"title": "Koa Middleware Deep Dive",
"content": "Understanding how Koa processes requests through its middleware stack.",
"published": true
}'
You should receive a response like this:
{
"success": true,
"data": {
"title": "Koa Middleware Deep Dive",
"content": "Understanding how Koa processes requests through its middleware stack.",
"published": true,
"_id": "687f609077d2025fcd25a5f8",
"createdAt": "2025-07-22T09:57:36.465Z",
"updatedAt": "2025-07-22T09:57:36.465Z"
},
"message": "Post created successfully"
}
For Postman testing, configure your request as shown:
Next, test the validation by sending invalid data:
curl -X POST http://localhost:3000/api/posts \
-H "Content-Type: application/json" \
-d '{
"title": "",
"content": ""
}'
The validation middleware catches the error before it reaches the controller:
{
"error": "Validation Failed",
"details": [
{
"field": "title",
"message": "\"title\" is not allowed to be empty"
},
{
"field": "content",
"message": "\"content\" is not allowed to be empty"
}
]
}
Your Koa.js blog API now demonstrates post creation using the framework's core strengths: elegant middleware composition and unified context handling.
Step 5 — Adding post retrieval and filtering capabilities
With post creation working, you'll now implement the read operations that showcase Koa's elegant handling of query parameters and asynchronous data retrieval. This step demonstrates how Koa's context object simplifies request processing compared to traditional frameworks.
Extend your posts router to include retrieval endpoints:
import Router from "@koa/router";
import { PostController } from "../controllers/PostController.js";
import { validate, schemas } from "../middleware/validation.js";
const router = new Router({ prefix: "/api/posts" });
// Middleware for all post routes
router.use(async (ctx, next) => {
// Add common headers
ctx.set("X-API-Version", "1.0");
ctx.set("X-Content-Type-Options", "nosniff");
await next();
});
// Create new post
router.post("/", validate(schemas.createPost, "body"), PostController.create);
// Get all posts with optional filtering
router.get("/", PostController.list);
// Get single post by ID
router.get("/:id", validate(schemas.postParams, "params"), PostController.getById);
export default router;
The GET routes demonstrate Koa's clean routing syntax. The /:id
route uses parameter validation to ensure valid MongoDB ObjectIds, while the list route handles query parameters automatically through ctx.query
.
Your PostController already includes the list and getById methods. Let's examine how they leverage Koa's context handling:
...
static async list(ctx) {
try {
const { published } = ctx.query;
const filter = {};
if (published !== undefined) {
filter.published = published === 'true';
}
const posts = await Post.find(filter)
.sort({ createdAt: -1 })
.lean();
ctx.body = {
success: true,
data: posts
};
} catch (error) {
console.error('List posts error:', error);
ctx.status = 500;
ctx.body = { error: 'Failed to retrieve posts' };
}
}
...
This method showcases Koa's query parameter handling through ctx.query
. Unlike Express where you access req.query
, Koa centralizes everything in the context object. The .lean()
method optimizes MongoDB queries by returning plain JavaScript objects instead of Mongoose documents.
The getById method demonstrates parameter validation in action:
...
static async getById(ctx) {
try {
const { id } = ctx.params;
const post = await Post.findById(id);
if (!post) {
ctx.status = 404;
ctx.body = { error: 'Post not found' };
return;
}
ctx.body = {
success: true,
data: post
};
} catch (error) {
if (error instanceof mongoose.CastError) {
ctx.status = 400;
ctx.body = { error: 'Invalid post ID format' };
} else {
console.error('Get post error:', error);
ctx.status = 500;
ctx.body = { error: 'Failed to retrieve post' };
}
}
}
Notice how the validation middleware already validated the id
parameter before this method runs. If the ID format is invalid, the request never reaches the controller—demonstrating Koa's middleware cascade protecting your business logic.
Test retrieving all posts:
curl http://localhost:3000/api/posts
You should see your created posts in the response:
{
"success": true,
"data": [
{
"_id": "687f619577d2025fcd25a5fa",
"title": "Koa Middleware Deep Dive",
"content": "Understanding how Koa processes requests through its middleware stack.",
"published": true,
"createdAt": "2025-07-22T10:01:57.926Z",
"updatedAt": "2025-07-22T10:01:57.926Z",
"__v": 0
},
...
]
}
Test retrieving a single post by ID:
curl http://localhost:3000/api/posts/687f619577d2025fcd25a5fa
Replace the ID with an actual post ID from your database. You'll receive the specific post data:
{
"success": true,
"data": {
"_id": "687f619577d2025fcd25a5fa",
"title": "Koa Middleware Deep Dive",
"content": "Understanding how Koa processes requests through its middleware stack.",
"published": true,
"createdAt": "2025-07-22T10:01:57.926Z",
"updatedAt": "2025-07-22T10:01:57.926Z"
}
}
Test the validation by using an invalid ID format:
curl http://localhost:3000/api/posts/invalid-id
The parameter validation middleware catches this before reaching the controller:
{
"error": "Validation Failed",
"details": [
{
"field": "id",
"message": "Invalid post ID format"
},
{
"field": "id",
"message": "Post ID must be 24 characters long"
}
]
}
For Postman testing, create GET requests as shown:
Your Koa.js blog API now offers comprehensive post retrieval with filtering and validation. The combination of middleware validation, context-based parameter handling, and asynchronous MongoDB operations demonstrates Koa's elegant approach to building robust APIs.
Next, you'll add update and delete operations to complete the full CRUD functionality.
Step 6 — Completing CRUD with update and delete operations
With creation and retrieval working, you'll now implement the final CRUD operations that showcase Koa's handling of HTTP methods and partial data updates. This step demonstrates how Koa's middleware composition elegantly handles complex validation scenarios.
Extend your posts router to include update and delete endpoints:
import Router from "@koa/router";
import { PostController } from "../controllers/PostController.js";
import { validate, schemas } from "../middleware/validation.js";
...
// Get single post by ID
router.get("/:id", validate(schemas.postParams, "params"), PostController.getById);
// Update existing post
router.put("/:id",
validate(schemas.postParams, "params"),
validate(schemas.updatePost, "body"),
PostController.update
);
// Delete post
router.delete("/:id",
validate(schemas.postParams, "params"),
PostController.delete
);
export default router;
The PUT route demonstrates Koa's middleware chaining at its finest. Two validation middlewares run in sequence—first validating the URL parameter, then the request body. This showcases how Koa composes functionality through small, focused middleware functions.
Add the update and delete methods to your PostController:
import { Post } from '../models/Post.js';
import mongoose from 'mongoose';
export class PostController {
// ... existing create, list, and getById methods
static async update(ctx) {
try {
const { id } = ctx.params;
const updateData = ctx.validatedData;
const post = await Post.findByIdAndUpdate(
id,
updateData,
{ new: true, runValidators: true }
);
if (!post) {
ctx.status = 404;
ctx.body = { error: 'Post not found' };
return;
}
ctx.body = {
success: true,
data: post,
message: 'Post updated successfully'
};
} catch (error) {
if (error.name === 'ValidationError') {
ctx.status = 400;
ctx.body = {
error: 'Validation failed',
details: Object.values(error.errors).map(err => err.message)
};
} else {
console.error('Update post error:', error);
ctx.status = 500;
ctx.body = { error: 'Failed to update post' };
}
}
}
static async delete(ctx) {
try {
const { id } = ctx.params;
const post = await Post.findByIdAndDelete(id);
if (!post) {
ctx.status = 404;
ctx.body = { error: 'Post not found' };
return;
}
ctx.status = 204;
} catch (error) {
console.error('Delete post error:', error);
ctx.status = 500;
ctx.body = { error: 'Failed to delete post' };
}
}
}
The update method uses findByIdAndUpdate
with runValidators: true
to ensure Mongoose schema validation runs on updates. The new: true
option returns the updated document. The delete method follows REST conventions by returning a 204 No Content status on successful deletion.
Test updating a post with a PUT request:
curl -X PUT http://localhost:3000/api/posts/687f619577d2025fcd25a5fa \
-H "Content-Type: application/json" \
-d '{
"title": "Advanced Koa.js Patterns",
"published": false
}'
Replace the ID with an actual post ID from your database. You should receive the updated post:
{
"success": true,
"data": {
"_id": "687f619577d2025fcd25a5fa",
"title": "Advanced Koa.js Patterns",
"content": "Understanding how Koa processes requests through its middleware stack.",
"published": false,
"createdAt": "2025-07-22T10:01:57.926Z",
"updatedAt": "2025-07-22T10:17:41.254Z"
},
"message": "Post updated successfully"
}
Notice how only the title and published status changed, while the content remained the same. This demonstrates partial updates working correctly.
Test the validation by sending invalid update data:
curl -X PUT http://localhost:3000/api/posts/687f619577d2025fcd25a5fa \
-H "Content-Type: application/json" \
-d '{
"title": "",
"published": "invalid"
}'
The validation middleware catches these errors:
{
"error": "Validation Failed",
"details": [
{
"field": "title",
"message": "\"title\" is not allowed to be empty"
},
{
"field": "published",
"message": "\"published\" must be a boolean"
}
]
}
Test deleting a post:
curl -X DELETE http://localhost:3000/api/posts/687f619577d2025fcd25a5fa
Test attempting to delete a non-existent post:
curl -X DELETE http://localhost:3000/api/posts/507f1f77bcf86cd799439011
This returns a 404 error:
{
"error": "Post not found"
}
Your Koa.js blog API now supports complete CRUD operations with comprehensive validation and error handling.
Final thoughts
This article explains how to build a simple blog API using Koa.js. It features modern capabilities like middleware and async/await for easier coding.
The article covers setup, connecting to MongoDB, and performing all basic operations with validations and error handling. Koa is a lightweight framework that makes web development straightforward and flexible.
To learn more, check out the [Koa.js documentation], the [Koa Router GitHub], [Mongoose], and [Joi].
Make your mark
Join the writer's program
Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.
Write for us
Build on top of Better Stack
Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.
community@betterstack.comor submit a pull request and help us build better products for everyone.
See the full list of amazing projects on github