Web Backend Development on Node.js (Koa)
Koa—minimalist framework from Express creators, reimagined for async/await. Where Express requires next() callbacks, Koa works through async/await and middleware stack executing "onion" principle: request passes middleware top-down, response—bottom-up.
Choose Koa when: need full freedom choosing libraries without framework opinions, but want proper async code handling unlike Express.
Middleware Pattern
import Koa from 'koa'
import Router from '@koa/router'
const app = new Koa()
// Logging middleware—wraps everything below
app.use(async (ctx, next) => {
const start = Date.now()
await next() // executes everything else
const ms = Date.now() - start
console.log(`${ctx.method} ${ctx.url} - ${ctx.status} - ${ms}ms`)
})
// Error handling
app.use(async (ctx, next) => {
try {
await next()
} catch (err) {
ctx.status = err.statusCode || err.status || 500
ctx.body = {
error: process.env.NODE_ENV === 'production' ? 'Internal Server Error' : err.message
}
}
})
Principal difference from Express: in Koa after await next() you return to middleware with access to final response state. In Express impossible without hacks.
Routing
@koa/router—official router:
import Router from '@koa/router'
import bodyParser from '@koa/bodyparser'
const router = new Router({ prefix: '/api/v1' })
router.get('/products', authenticate, async (ctx) => {
const { page = 1, limit = 20 } = ctx.query
const offset = (page - 1) * limit
const [items, total] = await Promise.all([
db.query('SELECT * FROM products LIMIT $1 OFFSET $2', [limit, offset]),
db.query('SELECT COUNT(*) FROM products')
])
ctx.body = {
data: items.rows,
pagination: {
page: Number(page),
limit: Number(limit),
total: Number(total.rows[0].count)
}
}
})
router.post('/products', authenticate, requireRole('admin'), async (ctx) => {
const data = ctx.request.body
const product = await ProductService.create(data)
ctx.status = 201
ctx.body = product
})
app.use(router.routes())
app.use(router.allowedMethods())
Validation Middleware
const validateBody = (schema) => {
return async (ctx, next) => {
try {
ctx.request.body = await schema.validate(ctx.request.body)
await next()
} catch (err) {
ctx.status = 422
ctx.body = { error: 'Validation failed', details: err.details }
}
}
}
router.post('/products', validateBody(createProductSchema), async (ctx) => {
// body already validated
})
Database Access
import { Pool } from 'pg'
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
// Middleware to attach db to context
app.use(async (ctx, next) => {
ctx.db = pool
await next()
})
// Or as service
class ProductService {
static async list(query) {
const result = await pool.query('SELECT * FROM products LIMIT $1', [query.limit])
return result.rows
}
}
Error Handling
class AppError extends Error {
constructor(message, statusCode) {
super(message)
this.statusCode = statusCode
}
}
app.use(async (ctx, next) => {
try {
await next()
} catch (err) {
if (err instanceof AppError) {
ctx.status = err.statusCode
ctx.body = { error: err.message }
} else {
ctx.status = 500
ctx.body = { error: 'Internal server error' }
}
}
})
// Usage
if (!product) {
throw new AppError('Product not found', 404)
}
Running Server
// index.js
const app = new Koa()
// Middleware stack
app.use(errorHandler)
app.use(bodyParser())
app.use(router.routes())
app.listen(process.env.PORT || 3000, () => {
console.log('Server running on port', process.env.PORT || 3000)
})
Timeline
Basic setup with routing and middleware—1 day. Database integration, authentication—2–3 days. Full API with validation, testing—1 week.
Koa suits small teams, greenfield projects, or when you prefer composing libraries over framework conventions.







