Build a REST API with Node.js and Express, Step by Step
From an empty folder to a validated, tested, production-shaped REST API — routing, error handling, validation and the project structure that scales.
Marcus Webb
Senior Writer
Hugo Nina
Fact Checked
Express remains the lingua franca of Node backends — not the newest framework, but the one whose patterns every Node developer is expected to know, and the fastest path from idea to running API. The problem is that most Express tutorials stop at “hello world plus two routes in app.js,” which teaches you Express without teaching you how to build an API you can live with.
This tutorial builds a small but production-shaped API — a task service with validation, clean error handling and tests. Everything runs with Node 22+ and Express 5. You can follow along from an empty folder.
Setup: an honest foundation
mkdir task-api && cd task-api
npm init -y
npm install express zod
npm install --save-dev vitest supertest
Enable ES modules and scripts in package.json:
{
"type": "module",
"scripts": {
"dev": "node --watch src/server.js",
"test": "vitest run"
}
}
node --watch gives you restart-on-save without nodemon. Two dependencies for the app itself is the correct amount of dependencies to start with.
Structure: features, not file types
src/
app.js ← Express app (no listening — important for tests)
server.js ← starts the server
config.js ← environment, validated once
middleware/
error.js
tasks/
tasks.routes.js
tasks.controller.js
tasks.service.js
tasks.schema.js
The split that matters: routes declare URLs, controllers translate HTTP ↔ domain, services hold logic and know nothing about HTTP. When you later swap the in-memory store for Postgres, or expose the same logic via a queue consumer, only one layer changes. This layering is also what keeps your options open between API styles — as we discuss in REST vs GraphQL vs tRPC, the transport should be swappable.
Config that fails fast
// src/config.js
const { PORT = '3000', NODE_ENV = 'development' } = process.env;
if (Number.isNaN(Number(PORT))) {
throw new Error(`PORT must be a number, got "${PORT}"`);
}
export const config = { port: Number(PORT), env: NODE_ENV };
Validating environment at startup means misconfiguration crashes loudly at deploy time — not silently at 3 a.m. when the first request hits.
Schema first: the boundary contract
// src/tasks/tasks.schema.js
import { z } from 'zod';
export const createTaskSchema = z.object({
title: z.string().min(1).max(200),
priority: z.enum(['low', 'medium', 'high']).default('medium'),
dueDate: z.coerce.date().optional(),
});
export const updateTaskSchema = createTaskSchema.partial();
Every byte of req.body is attacker-controlled input until proven otherwise. A schema library gives you validation, coercion, defaults and — crucially — types you can trust downstream.
Service: logic without HTTP
// src/tasks/tasks.service.js
import { randomUUID } from 'node:crypto';
const tasks = new Map(); // swap for a DB later; the interface stays
export function listTasks({ status } = {}) {
const all = [...tasks.values()];
return status ? all.filter((t) => t.status === status) : all;
}
export function getTask(id) {
return tasks.get(id) ?? null;
}
export function createTask(data) {
const task = { id: randomUUID(), status: 'open', createdAt: new Date(), ...data };
tasks.set(task.id, task);
return task;
}
export function updateTask(id, patch) {
const existing = tasks.get(id);
if (!existing) return null;
const updated = { ...existing, ...patch, updatedAt: new Date() };
tasks.set(id, updated);
return updated;
}
No req, no res, no status codes. You could call this from a CLI, a test, or a cron job unchanged.
Errors: one place, one shape
The core trick of maintainable Express: controllers throw, one middleware answers.
// src/middleware/error.js
export class HttpError extends Error {
constructor(status, message, details) {
super(message);
this.status = status;
this.details = details;
}
}
export function errorHandler(err, req, res, next) {
if (err instanceof HttpError) {
return res.status(err.status).json({ error: err.message, details: err.details });
}
console.error(err); // real apps: structured logger
res.status(500).json({ error: 'Internal server error' });
}
Every error response in the whole API now has the same shape — clients love you, and there’s exactly one place to add error tracking later.
Controller and routes
// src/tasks/tasks.controller.js
import * as service from './tasks.service.js';
import { createTaskSchema, updateTaskSchema } from './tasks.schema.js';
import { HttpError } from '../middleware/error.js';
export function list(req, res) {
res.json(service.listTasks({ status: req.query.status }));
}
export function get(req, res) {
const task = service.getTask(req.params.id);
if (!task) throw new HttpError(404, 'Task not found');
res.json(task);
}
export function create(req, res) {
const parsed = createTaskSchema.safeParse(req.body);
if (!parsed.success) throw new HttpError(400, 'Invalid task', parsed.error.flatten());
res.status(201).json(service.createTask(parsed.data));
}
export function update(req, res) {
const parsed = updateTaskSchema.safeParse(req.body);
if (!parsed.success) throw new HttpError(400, 'Invalid patch', parsed.error.flatten());
const task = service.updateTask(req.params.id, parsed.data);
if (!task) throw new HttpError(404, 'Task not found');
res.json(task);
}
// src/tasks/tasks.routes.js
import { Router } from 'express';
import * as controller from './tasks.controller.js';
export const tasksRouter = Router();
tasksRouter.get('/', controller.list);
tasksRouter.post('/', controller.create);
tasksRouter.get('/:id', controller.get);
tasksRouter.patch('/:id', controller.update);
A note on those bare throws in handlers: Express 5 forwards rejected promises and thrown errors from async handlers to the error middleware automatically — the historical footgun where an async throw crashed or hung the request is finally gone. On Express 4 you’d wrap handlers in a catchAsync helper; if you maintain older services, keep that habit.
Assembling the app
// src/app.js
import express from 'express';
import { tasksRouter } from './tasks/tasks.routes.js';
import { errorHandler } from './middleware/error.js';
export const app = express();
app.use(express.json({ limit: '100kb' }));
app.get('/health', (req, res) => res.json({ status: 'ok', uptime: process.uptime() }));
app.use('/api/tasks', tasksRouter);
app.use((req, res) => res.status(404).json({ error: 'Route not found' }));
app.use(errorHandler);
// src/server.js
import { app } from './app.js';
import { config } from './config.js';
app.listen(config.port, () => {
console.log(`API listening on http://localhost:${config.port}`);
});
Separating app from server looks pedantic until you write your first test — then it’s the whole trick: tests import the app without opening a port.
Tests that hit the real thing
// src/tasks/tasks.test.js
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { app } from '../app.js';
describe('tasks API', () => {
it('creates and fetches a task', async () => {
const created = await request(app)
.post('/api/tasks')
.send({ title: 'Write tests', priority: 'high' })
.expect(201);
await request(app).get(`/api/tasks/${created.body.id}`).expect(200);
});
it('rejects garbage input with details', async () => {
const res = await request(app).post('/api/tasks').send({ title: '' }).expect(400);
expect(res.body.error).toBe('Invalid task');
});
it('404s unknown tasks with the standard error shape', async () => {
const res = await request(app).get('/api/tasks/nope').expect(404);
expect(res.body).toEqual({ error: 'Task not found' });
});
});
npm test — three green checks, full request-to-response coverage, no mocks of your own code. Supertest through the real app catches the bugs unit tests politely step around: middleware order, JSON parsing, status codes.
Where to go from here
The skeleton extends in predictable directions: swap the Map for Postgres behind the same service functions; add auth as middleware (app.use('/api', requireAuth)); add helmet for headers, a rate limiter, and pino for structured logs; document with an OpenAPI spec generated from your Zod schemas (several libraries do this) rather than hand-written YAML that drifts.
And when it’s time to ship it somewhere, containerizing this exact API is the worked example in our Docker for beginners tutorial — the two articles are designed to chain.
Final Thoughts
None of the patterns here are clever, and that’s the point: schema at the boundary, logic in services, errors in one middleware, tests through the front door. This is the boring architecture that lets an Express API grow from four routes to forty without anyone dreading the codebase — and it’s why Express, version 5 and decades of conventions in hand, is still a superb default for learning how backends really work. Build this once from scratch and every framework you touch afterward — Fastify, NestJS, Hono — will feel like familiar furniture, rearranged.