Most Node.js APIs start the same way.
A server.js file. A few routes. Maybe an app.js if you've read a tutorial. Everything in one place because the app is small and there's no reason to complicate it yet.
Then the app grows.
Routes multiply. Business logic leaks into route handlers. Error handling is copy-pasted across files with slight variations. A new developer joins and spends their first week just trying to understand where things live.
The API still works. But it's become a place nobody wants to touch.
This guide is about building the structure that prevents that outcome — from the first route to a production-ready layer with validation, versioning, centralized error handling, rate limiting, and auto-generated documentation.
We'll build it incrementally, so every step is independently useful even if you stop halfway through.
TL;DR
- Clean API design in Node.js is mostly about separation of concerns: routes, controllers, services, and validation each live in their own layer.
- Zod handles validation at the boundary — before business logic ever runs.
- Centralized error handling is the single change that improves the most codebases the fastest.
- Rate limiting, API versioning, and OpenAPI docs are not advanced topics — they're table stakes for any API that will be used by someone else.
Table of Contents
- What We're Building
- Step 1: Project Structure
- Step 2: The Express App Setup
- Step 3: Routing and Controllers — Separating Concerns
- Step 4: The Service Layer — Keeping Business Logic Out of Controllers
- Step 5: Validation with Zod — Stop Trusting Your Inputs
- Step 6: Centralized Error Handling
- Step 7: Response Shaping — Consistent API Responses
- Step 8: API Versioning
- Step 9: Rate Limiting
- Step 10: OpenAPI Documentation with Swagger
- Why You Might Consider Fastify Instead
- Final Thoughts
What We're Building
We'll build a REST API for a simple product catalog — products with a name, price, and category. The domain is intentionally simple so the focus stays on structure, not business logic.
By the end, the API will have:
- Clean separation between routing, controllers, and services
- Request validation with Zod
- Centralized error handling
- Consistent response shaping
- API versioning (
/api/v1/) - Rate limiting
- Auto-generated Swagger documentation Let's start from zero.
Step 1: Project Structure
Before writing a single line of code, let's define where things live.
src/
├── api/
│ └── v1/
│ ├── products/
│ │ ├── products.router.ts
│ │ ├── products.controller.ts
│ │ ├── products.service.ts
│ │ └── products.schema.ts
│ └── index.ts
│
├── middleware/
│ ├── errorHandler.ts
│ ├── rateLimiter.ts
│ └── validateRequest.ts
│
├── lib/
│ ├── AppError.ts
│ └── responseHelper.ts
│
├── app.ts
└── server.ts
The logic behind this structure:
-
api/v1/— all routes are versioned from day one. Addingv2later is a folder, not a refactor. - Feature folders (
products/) — every domain owns its router, controller, service, and schema. Adding a new domain means adding a new folder, not touching existing files. -
middleware/— cross-cutting concerns that apply to multiple routes.
- lib/ — shared utilities with no framework dependency.
Step 2: The Express App Setup
// src/app.ts
import express, { Application } from 'express'
import helmet from 'helmet'
import cors from 'cors'
import { rateLimiter } from './middleware/rateLimiter'
import { errorHandler } from './middleware/errorHandler'
import { setupSwagger } from './lib/swagger'
import v1Router from './api/v1'
export function createApp(): Application {
const app = express()
// Security headers
app.use(helmet())
// CORS
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') ?? '*',
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
}))
// Body parsing
app.use(express.json({ limit: '10kb' }))
// Rate limiting — applied globally before any route
app.use(rateLimiter)
// API routes
app.use('/api/v1', v1Router)
// Swagger docs
setupSwagger(app)
// 404 handler — must come after all routes
app.use((req, res) => {
res.status(404).json({
success: false,
error: { code: 'NOT_FOUND', message: `Route ${req.method} ${req.path} not found` },
})
})
// Centralized error handler — must be last
app.use(errorHandler)
return app
}
// src/server.ts
import { createApp } from './app'
const PORT = process.env.PORT ?? 3000
const app = createApp()
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`)
})
Two files, two responsibilities. app.ts defines the application. server.ts starts it. This separation makes testing significantly easier — you can import createApp() in tests without starting a real server.
Step 3: Routing and Controllers — Separating Concerns
The most common mistake in Express apps is putting business logic inside route handlers:
// ❌ Everything in one place — this is how the mess starts
app.get('/products/:id', async (req, res) => {
try {
const product = products.find(p => p.id === req.params.id)
if (!product) {
return res.status(404).json({ error: 'Product not found' })
}
res.json(product)
} catch (error) {
res.status(500).json({ error: 'Something went wrong' })
}
})
The route handler is doing three things: routing, business logic, and error handling. As the application grows, this becomes unmaintainable.
The fix is a clean separation between the router (where) and the controller (what):
// src/api/v1/products/products.router.ts
import { Router } from 'express'
import { productController } from './products.controller'
import { validateRequest } from '@/middleware/validateRequest'
import {
createProductSchema,
updateProductSchema,
productIdSchema,
} from './products.schema'
const router = Router()
router.get('/', productController.getAll)
router.get('/:id', validateRequest({ params: productIdSchema }), productController.getById)
router.post('/', validateRequest({ body: createProductSchema }), productController.create)
router.put('/:id',
validateRequest({ params: productIdSchema, body: updateProductSchema }),
productController.update
)
router.delete('/:id', validateRequest({ params: productIdSchema }), productController.remove)
export default router
// src/api/v1/products/products.controller.ts
import { Request, Response, NextFunction } from 'express'
import { productService } from './products.service'
import { sendSuccess } from '@/lib/responseHelper'
import { AppError } from '@/lib/AppError'
export const productController = {
async getAll(req: Request, res: Response, next: NextFunction) {
try {
const products = await productService.getAll()
sendSuccess(res, products)
} catch (error) {
next(error)
}
},
async getById(req: Request, res: Response, next: NextFunction) {
try {
const product = await productService.getById(req.params.id)
if (!product) throw new AppError('Product not found', 404, 'NOT_FOUND')
sendSuccess(res, product)
} catch (error) {
next(error)
}
},
async create(req: Request, res: Response, next: NextFunction) {
try {
const product = await productService.create(req.body)
sendSuccess(res, product, 201)
} catch (error) {
next(error)
}
},
async update(req: Request, res: Response, next: NextFunction) {
try {
const product = await productService.update(req.params.id, req.body)
if (!product) throw new AppError('Product not found', 404, 'NOT_FOUND')
sendSuccess(res, product)
} catch (error) {
next(error)
}
},
async remove(req: Request, res: Response, next: NextFunction) {
try {
await productService.remove(req.params.id)
sendSuccess(res, null, 204)
} catch (error) {
next(error)
}
},
}
The controller's only job is to translate HTTP into service calls and back. It doesn't know how products are stored. It doesn't know the business rules. It knows HTTP.
Notice the next(error) pattern — every caught error is forwarded to the centralized error handler, which we'll define in Step 6.
Step 4: The Service Layer — Keeping Business Logic Out of Controllers
The service layer is where business logic lives. It knows nothing about HTTP — no req, no res, no status codes.
// src/api/v1/products/products.service.ts
import { Product, CreateProductPayload, UpdateProductPayload } from './products.schema'
import { AppError } from '@/lib/AppError'
// In-memory mock — replace with your DB layer
const mockProducts: Product[] = [
{ id: '1', name: 'Wireless Keyboard', price: 79.99, category: 'Electronics', createdAt: new Date().toISOString() },
{ id: '2', name: 'Standing Desk', price: 349.00, category: 'Furniture', createdAt: new Date().toISOString() },
{ id: '3', name: 'Noise-Cancelling Headphones', price: 199.99, category: 'Electronics', createdAt: new Date().toISOString() },
]
let nextId = 4
export const productService = {
async getAll(): Promise<Product[]> {
return mockProducts
},
async getById(id: string): Promise<Product | null> {
return mockProducts.find(p => p.id === id) ?? null
},
async create(payload: CreateProductPayload): Promise<Product> {
// Business rule: no duplicate product names
const existing = mockProducts.find(
p => p.name.toLowerCase() === payload.name.toLowerCase()
)
if (existing) {
throw new AppError('A product with this name already exists', 409, 'CONFLICT')
}
const product: Product = {
id: String(nextId++),
...payload,
createdAt: new Date().toISOString(),
}
mockProducts.push(product)
return product
},
async update(id: string, payload: UpdateProductPayload): Promise<Product | null> {
const index = mockProducts.findIndex(p => p.id === id)
if (index === -1) return null
mockProducts[index] = { ...mockProducts[index], ...payload }
return mockProducts[index]
},
async remove(id: string): Promise<void> {
const index = mockProducts.findIndex(p => p.id === id)
if (index === -1) {
throw new AppError('Product not found', 404, 'NOT_FOUND')
}
mockProducts.splice(index, 1)
},
}
The service throws AppError for business rule violations — not HTTP errors. The controller translates those into HTTP responses. The layers don't bleed into each other.
When you replace the mock with a real database, you only touch the service. The controller, the router, the validation — none of them change.
Step 5: Validation with Zod — Stop Trusting Your Inputs
Every API that accepts external input needs validation. Without it, you're one malformed request away from a runtime error, a data integrity problem, or a security vulnerability.
Zod lets you define a schema and validate against it with full TypeScript inference — the same schema gives you runtime validation and compile-time types.
// src/api/v1/products/products.schema.ts
import { z } from 'zod'
const CATEGORIES = ['Electronics', 'Furniture', 'Clothing', 'Books', 'Other'] as const
export const createProductSchema = z.object({
name: z.string()
.min(2, 'Name must be at least 2 characters')
.max(100, 'Name must be at most 100 characters')
.trim(),
price: z.number()
.positive('Price must be a positive number')
.multipleOf(0.01, 'Price must have at most 2 decimal places'),
category: z.enum(CATEGORIES, {
errorMap: () => ({ message: `Category must be one of: ${CATEGORIES.join(', ')}` }),
}),
})
export const updateProductSchema = createProductSchema.partial()
export const productIdSchema = z.object({
id: z.string().min(1, 'Product ID is required'),
})
// TypeScript types derived directly from the schemas
export type Product = z.infer<typeof createProductSchema> & {
id: string
createdAt: string
}
export type CreateProductPayload = z.infer<typeof createProductSchema>
export type UpdateProductPayload = z.infer<typeof updateProductSchema>
Now the validateRequest middleware that the router uses:
// src/middleware/validateRequest.ts
import { Request, Response, NextFunction } from 'express'
import { ZodSchema, ZodError } from 'zod'
import { AppError } from '@/lib/AppError'
interface ValidationSchemas {
body?: ZodSchema
params?: ZodSchema
query?: ZodSchema
}
export function validateRequest(schemas: ValidationSchemas) {
return (req: Request, res: Response, next: NextFunction) => {
try {
if (schemas.body) {
req.body = schemas.body.parse(req.body)
}
if (schemas.params) {
req.params = schemas.params.parse(req.params)
}
if (schemas.query) {
req.query = schemas.query.parse(req.query)
}
next()
} catch (error) {
if (error instanceof ZodError) {
const details = error.errors.map(e => ({
field: e.path.join('.'),
message: e.message,
}))
next(new AppError('Validation failed', 400, 'VALIDATION_ERROR', details))
} else {
next(error)
}
}
}
}
Validation happens at the boundary — before the request reaches the controller. If the input is invalid, the request never gets further. The controller and service can trust that req.body and req.params are exactly the shape they expect.
Step 6: Centralized Error Handling
Scattered error handling is one of the most common problems in Node.js APIs. Some routes return { error: "message" }. Others return { message: "error" }. Some return HTML error pages by accident. None of them are consistent.
The fix is a single error handler that every error flows through:
// src/lib/AppError.ts
export class AppError extends Error {
constructor(
public readonly message: string,
public readonly statusCode: number = 500,
public readonly code: string = 'INTERNAL_ERROR',
public readonly details?: unknown
) {
super(message)
this.name = 'AppError'
}
}
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express'
import { AppError } from '@/lib/AppError'
import { ZodError } from 'zod'
export function errorHandler(
error: unknown,
req: Request,
res: Response,
next: NextFunction
) {
// Known application error
if (error instanceof AppError) {
return res.status(error.statusCode).json({
success: false,
error: {
code: error.code,
message: error.message,
...(error.details ? { details: error.details } : {}),
},
})
}
// Unhandled Zod error (shouldn't reach here, but just in case)
if (error instanceof ZodError) {
return res.status(400).json({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: 'Validation failed',
details: error.errors,
},
})
}
// JSON parse errors from express.json()
if (error instanceof SyntaxError && 'body' in error) {
return res.status(400).json({
success: false,
error: {
code: 'INVALID_JSON',
message: 'Request body contains invalid JSON',
},
})
}
// Unknown errors — log and return generic response
console.error('[Unhandled Error]', error)
return res.status(500).json({
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
},
})
}
Every error in the entire application flows through this single function. Every error response has the same shape. Debugging is easier. Client-side error handling is easier. Adding logging or monitoring is a one-line change in one place.
Step 7: Response Shaping — Consistent API Responses
Error responses are consistent. Success responses should be too.
// src/lib/responseHelper.ts
import { Response } from 'express'
interface SuccessResponse<T> {
success: true
data: T
meta?: Record<string, unknown>
}
export function sendSuccess<T>(
res: Response,
data: T,
statusCode = 200,
meta?: Record<string, unknown>
): void {
if (statusCode === 204) {
res.status(204).send()
return
}
const response: SuccessResponse<T> = {
success: true,
data,
...(meta ? { meta } : {}),
}
res.status(statusCode).json(response)
}
Every successful response now looks like this:
{
"success": true,
"data": {
"id": "1",
"name": "Wireless Keyboard",
"price": 79.99,
"category": "Electronics",
"createdAt": "2025-06-01T10:00:00.000Z"
}
}
Every error response looks like this:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [
{ "field": "price", "message": "Price must be a positive number" }
]
}
}
Consistent. Predictable. Easy to consume from a frontend or a third-party client.
Step 8: API Versioning
Versioning from day one costs almost nothing. Adding it later costs a lot.
// src/api/v1/index.ts
import { Router } from 'express'
import productsRouter from './products/products.router'
const v1Router = Router()
v1Router.use('/products', productsRouter)
// v1Router.use('/orders', ordersRouter)
// v1Router.use('/users', usersRouter)
export default v1Router
In app.ts, the router is already mounted at /api/v1. When you need a breaking change, you create src/api/v2/, mount it at /api/v2, and both versions coexist without conflict.
// app.ts — adding v2 is a two-line change
app.use('/api/v1', v1Router)
app.use('/api/v2', v2Router)
No migration pain. No breaking existing clients.
Step 9: Rate Limiting
Rate limiting protects your API from abuse — intentional or accidental. It's a one-time setup with express-rate-limit:
// src/middleware/rateLimiter.ts
import rateLimit from 'express-rate-limit'
export const rateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // max 100 requests per window per IP
standardHeaders: true, // return rate limit info in headers
legacyHeaders: false,
message: {
success: false,
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: 'Too many requests, please try again later.',
},
},
})
// Stricter limiter for write operations
export const writeLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: {
success: false,
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: 'Too many write requests, please slow down.',
},
},
})
Apply the stricter limiter to mutation routes:
// products.router.ts — add writeLimiter to POST, PUT, DELETE
import { writeLimiter } from '@/middleware/rateLimiter'
router.post('/', writeLimiter, validateRequest({ body: createProductSchema }), productController.create)
router.put('/:id', writeLimiter, validateRequest({ params: productIdSchema, body: updateProductSchema }), productController.update)
router.delete('/:id', writeLimiter, validateRequest({ params: productIdSchema }), productController.remove)
Step 10: OpenAPI Documentation with Swagger
An API without documentation is an API that only you can use.
swagger-jsdoc generates an OpenAPI spec from JSDoc comments. swagger-ui-express serves it as an interactive UI.
// src/lib/swagger.ts
import swaggerJsdoc from 'swagger-jsdoc'
import swaggerUi from 'swagger-ui-express'
import { Application } from 'express'
const options: swaggerJsdoc.Options = {
definition: {
openapi: '3.0.0',
info: {
title: 'Product Catalog API',
version: '1.0.0',
description: 'A clean REST API for managing products',
},
servers: [{ url: '/api/v1' }],
},
apis: ['./src/api/**/*.router.ts'],
}
const spec = swaggerJsdoc(options)
export function setupSwagger(app: Application): void {
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(spec))
}
Now add JSDoc annotations to your router:
// products.router.ts — with Swagger annotations
/**
* @swagger
* /products:
* get:
* summary: Get all products
* tags: [Products]
* responses:
* 200:
* description: List of products
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* data:
* type: array
* items:
* $ref: '#/components/schemas/Product'
*
* /products/{id}:
* get:
* summary: Get a product by ID
* tags: [Products]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Product found
* 404:
* description: Product not found
*/
The interactive documentation is now available at http://localhost:3000/api/docs. Every endpoint is documented, explorable, and testable directly from the browser.
Why You Might Consider Fastify Instead
Everything we've built works well with Express. But if you're starting a new project in 2025, Fastify deserves serious consideration.
Here's an honest comparison:
| Express | Fastify | |
|---|---|---|
| Performance | Good | ~2x faster (benchmarks) |
| TypeScript support | Manual setup | First-class, built-in |
| Schema validation | Via middleware (Zod, Joi) | Built-in (JSON Schema / Zod plugin) |
| Plugin ecosystem | Massive, mature | Smaller, but growing fast |
| Learning curve | Very low | Low |
| Logging | Manual (Winston, Pino) | Built-in (Pino) |
| OpenAPI generation | Manual (swagger-jsdoc) | Via @fastify/swagger
|
The most important differences in practice:
Performance. Fastify is consistently faster than Express in benchmarks — sometimes significantly. For most applications, this doesn't matter at all. For high-throughput APIs, it can.
TypeScript. Fastify was designed with TypeScript in mind. Route handlers are typed end-to-end with no extra configuration. In Express, you need to augment Request types manually.
Schema-first validation. Fastify uses JSON Schema (or Zod via a plugin) for validation and serialization. Responses are validated before they're sent, which means TypeScript types and runtime behavior are guaranteed to match.
When to stick with Express:
- You're joining an existing Express codebase.
- Your team already knows Express deeply.
- You rely on Express-specific middleware that has no Fastify equivalent.
- You want the largest possible ecosystem of tutorials, Stack Overflow answers, and community resources. When to consider Fastify:
- You're starting a new project from scratch.
- TypeScript support matters to you out of the box.
- You care about raw performance.
- You want built-in logging and schema validation without extra setup. The architecture we've built in this article — feature folders, controller/service separation, centralized error handling, versioning — applies equally to Fastify. The structural principles don't change. Only the framework-specific syntax does.
{% embed https://fastify.dev %}
Final Thoughts
We started with a server.js file and a vague sense that things should be better organized.
We ended with a structure that scales:
- Routes declare what exists and what middleware applies.
- Controllers translate HTTP into service calls.
- Services contain business logic, framework-agnostic.
- Schemas define and validate the contract at the boundary.
- Middleware handles cross-cutting concerns once, globally. None of these steps are complicated individually. The value is in the combination — and in doing it from the start, before the codebase makes it expensive.
The next time you start a Node.js API, resist the temptation to put everything in one file "just for now." The structure we've built here isn't overengineering — it's the minimum that makes a backend maintainable by more than one person.
What does your current Express setup look like?
Are you working with a structure like this, or inheriting something that grew organically? Drop your setup — or your horror story — in the comments.
If this was useful, a ❤️ or a 🦄 helps it reach more backend developers who need it.
And follow along for the next article in the series.
Top comments (0)