Project Setup
mkdir task-api && cd task-api
npm init -y
npm install express zod jsonwebtoken bcryptjs helmet cors express-rate-limit
npm install --save-dev nodemon{
"name": "task-api",
"type": "module",
"engines": { "node": ">=22" },
"scripts": {
"start": "node src/server.js",
"dev": "node --watch --env-file=.env src/server.js",
"test": "node --test"
}
}"type": "module" switches the project to ES modules, so you write import instead of require. Do it on a new project — the ecosystem has moved.
Structuring the Project
Express gives you no structure at all, so pick one before the second file. This layout scales from a weekend project to a real service:
src/
server.js # starts the HTTP listener — nothing else
app.js # builds and configures the Express app
config.js # validated environment config
routes/
index.js
tasks.routes.js
auth.routes.js
controllers/
tasks.controller.js
services/
tasks.service.js # business logic — no req/res in here
middleware/
auth.js
error-handler.js
validate.js
schemas/
task.schema.js
db/
index.jsSplitting server.js from app.js looks fussy until you write your first test — then it is what lets you import the app without binding a port.
// src/app.js
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import routes from './routes/index.js';
import { errorHandler, notFound } from './middleware/error-handler.js';
export function createApp() {
const app = express();
app.use(helmet());
app.use(cors({ origin: process.env.CORS_ORIGIN?.split(',') ?? [] }));
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
app.get('/health', (req, res) => res.json({ status: 'ok', uptime: process.uptime() }));
app.use('/api/v1', routes);
app.use(notFound);
app.use(errorHandler); // must be LAST
return app;
}// src/server.js
import { createApp } from './app.js';
import config from './config.js';
const app = createApp();
const server = app.listen(config.port, () =>
console.log(`API listening on :${config.port}`));
// Graceful shutdown — finish in-flight requests before exiting
for (const signal of ['SIGTERM', 'SIGINT']) {
process.on(signal, () => {
console.log(`${signal} received, closing server`);
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 10_000).unref();
});
}Graceful shutdown is not optional on a container platform. Kubernetes sends SIGTERM and then kills the process; without server.close() every in-flight request is dropped on every deploy.
Routing and Controllers
// src/routes/tasks.routes.js
import { Router } from 'express';
import * as controller from '../controllers/tasks.controller.js';
import { requireAuth } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import { createTaskSchema, updateTaskSchema } from '../schemas/task.schema.js';
const router = Router();
router.use(requireAuth);
router.get('/', controller.index);
router.get('/:id', controller.show);
router.post('/', validate(createTaskSchema), controller.store);
router.patch('/:id', validate(updateTaskSchema), controller.update);
router.delete('/:id', controller.destroy);
export default router;// src/routes/index.js
import { Router } from 'express';
import tasks from './tasks.routes.js';
import auth from './auth.routes.js';
const router = Router();
router.use('/auth', auth);
router.use('/tasks', tasks);
export default router;Keep controllers thin. They read the request, call a service, and shape the response — nothing else:
// src/controllers/tasks.controller.js
import * as service from '../services/tasks.service.js';
import { NotFoundError } from '../lib/errors.js';
export async function index(req, res) {
const { page = 1, limit = 20, status } = req.query;
const result = await service.list({
userId: req.user.id,
page: Number(page),
limit: Math.min(Number(limit), 100), // cap it — never trust the client
status,
});
res.json(result);
}
export async function show(req, res) {
const task = await service.findForUser(req.params.id, req.user.id);
if (!task) throw new NotFoundError('Task not found');
res.json({ data: task });
}
export async function store(req, res) {
const task = await service.create({ ...req.validated, userId: req.user.id });
res.status(201).json({ data: task });
}Business logic lives in the service, where it can be tested without an HTTP request and reused from a CLI command or a queue worker.
| Method | Path | Meaning | Success status |
|---|---|---|---|
| GET | /tasks | List | 200 |
| GET | /tasks/:id | Fetch one | 200 |
| POST | /tasks | Create | 201 |
| PATCH | /tasks/:id | Partial update | 200 |
| PUT | /tasks/:id | Full replace | 200 |
| DELETE | /tasks/:id | Remove | 204 |
Middleware
Middleware is a function with the signature (req, res, next) that runs before your route handler. Order is everything — Express executes them top to bottom, exactly as registered.
// A logging middleware
export function requestLogger(req, res, next) {
const start = process.hrtime.bigint();
res.on('finish', () => {
const ms = Number(process.hrtime.bigint() - start) / 1e6;
console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${ms.toFixed(1)}ms`);
});
next();
}Three rules that cover almost every middleware bug:
- Call
next()or send a response. Do neither and the request hangs until the client times out. - Do not do both. Calling
next()afterres.json()produces “Cannot set headers after they are sent”. - Error middleware has four arguments.
(err, req, res, next)— Express identifies it by arity, so you cannot omit the unusednext.
Validating Input
Never trust a request body. Zod gives you validation and a parsed, typed result in one step:
// src/schemas/task.schema.js
import { z } from 'zod';
export const createTaskSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().max(5000).optional(),
dueAt: z.coerce.date().optional(),
priority: z.enum(['low', 'normal', 'high']).default('normal'),
tagIds: z.array(z.number().int().positive()).max(10).default([]),
});
// Partial update — every field optional, but at least one required
export const updateTaskSchema = createTaskSchema.partial().refine(
(data) => Object.keys(data).length > 0,
{ message: 'Provide at least one field to update' },
);// src/middleware/validate.js
import { ValidationError } from '../lib/errors.js';
export const validate = (schema, source = 'body') => (req, res, next) => {
const result = schema.safeParse(req[source]);
if (!result.success) {
return next(new ValidationError(result.error.flatten().fieldErrors));
}
req.validated = result.data; // use this, not req.body
next();
};Use req.validated in your controller, not req.body. The parsed object contains only the fields your schema declared, with defaults applied and types coerced — which is mass-assignment protection you get for free.
Async Error Handling
This is the single biggest Express gotcha. In Express 4, a rejected promise inside an async handler is not caught — the request hangs forever.
// Express 4 — this hangs if the query rejects
app.get('/tasks', async (req, res) => {
const tasks = await db.query('...'); // throws -> unhandled rejection
res.json(tasks);
});Express 5 fixed this: rejected promises are forwarded to the error handler automatically. If you are on 4, wrap your handlers:
// src/lib/async-handler.js
export const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
// Usage
router.get('/', asyncHandler(controller.index));Then define typed errors and one handler that turns them into responses:
// src/lib/errors.js
export class AppError extends Error {
constructor(message, status = 500, code = 'INTERNAL_ERROR', details) {
super(message);
this.status = status;
this.code = code;
this.details = details;
this.isOperational = true;
}
}
export class NotFoundError extends AppError { constructor(m = 'Not found') { super(m, 404, 'NOT_FOUND'); } }
export class UnauthorizedError extends AppError { constructor(m = 'Unauthorized') { super(m, 401, 'UNAUTHORIZED'); } }
export class ForbiddenError extends AppError { constructor(m = 'Forbidden') { super(m, 403, 'FORBIDDEN'); } }
export class ValidationError extends AppError { constructor(details) { super('Validation failed', 422, 'VALIDATION_FAILED', details); } }// src/middleware/error-handler.js
import { NotFoundError } from '../lib/errors.js';
export function notFound(req, res, next) {
next(new NotFoundError(`No route for ${req.method} ${req.originalUrl}`));
}
// Four arguments — Express identifies error middleware by arity
export function errorHandler(err, req, res, next) {
const status = err.status ?? 500;
if (status >= 500) {
console.error({ msg: err.message, stack: err.stack, path: req.originalUrl });
}
res.status(status).json({
error: {
code: err.code ?? 'INTERNAL_ERROR',
// Never leak an internal message or stack trace to a client
message: status >= 500 ? 'Something went wrong' : err.message,
...(err.details && { details: err.details }),
},
});
}Leaking err.message on a 500 is a real information disclosure — database errors happily include table names, column names and sometimes query values. Log the detail server-side; return something generic.
Authentication With JWT
// src/routes/auth.routes.js
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import config from '../config.js';
router.post('/register', validate(registerSchema), asyncHandler(async (req, res) => {
const { email, password } = req.validated;
const existing = await db.users.findByEmail(email);
if (existing) throw new AppError('Email already registered', 409, 'EMAIL_TAKEN');
const user = await db.users.create({
email,
passwordHash: await bcrypt.hash(password, 12),
});
res.status(201).json({ data: { id: user.id, email: user.email } });
}));
router.post('/login', validate(loginSchema), asyncHandler(async (req, res) => {
const { email, password } = req.validated;
const user = await db.users.findByEmail(email);
// Compare unconditionally so timing does not reveal whether the email exists
const ok = user && await bcrypt.compare(password, user.passwordHash);
if (!ok) throw new UnauthorizedError('Invalid credentials');
const token = jwt.sign(
{ sub: user.id, email: user.email },
config.jwtSecret,
{ expiresIn: '15m', issuer: 'task-api' },
);
res.json({ token, expiresIn: 900 });
}));// src/middleware/auth.js
import jwt from 'jsonwebtoken';
import config from '../config.js';
import { UnauthorizedError } from '../lib/errors.js';
export function requireAuth(req, res, next) {
const header = req.headers.authorization ?? '';
const [scheme, token] = header.split(' ');
if (scheme !== 'Bearer' || !token) {
return next(new UnauthorizedError('Missing bearer token'));
}
try {
const payload = jwt.verify(token, config.jwtSecret, { issuer: 'task-api' });
req.user = { id: payload.sub, email: payload.email };
next();
} catch {
next(new UnauthorizedError('Invalid or expired token'));
}
}Keep access tokens short-lived (10–15 minutes) and pair them with a refresh token stored in an HttpOnly cookie. A JWT cannot be revoked before it expires — that is the trade you accept for statelessness, and a short lifetime is how you limit the damage.
Consistent Responses
Pick one envelope and use it everywhere. Clients should never have to guess where the data is.
// Single resource
{ "data": { "id": 1, "title": "Ship the API" } }
// Collection with pagination
{
"data": [ ... ],
"meta": { "page": 1, "limit": 20, "total": 137, "totalPages": 7 }
}
// Error
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Validation failed",
"details": { "title": ["String must contain at least 1 character(s)"] }
}
}Two more things worth doing early: version the URL (/api/v1) so you can change the contract later, and use cursor pagination instead of OFFSET once collections get large — deep offsets get slow, and rows shift under the client between pages.
Production Hardening
import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import compression from 'compression';
app.set('trust proxy', 1); // behind a load balancer — needed for real client IPs
app.use(helmet()); // sensible security headers
app.use(compression());
app.use(express.json({ limit: '1mb' })); // cap the body size
app.use(cors({
origin: ['https://app.example.com'], // never `true` or '*' with credentials
credentials: true,
}));
app.use('/api', rateLimit({
windowMs: 15 * 60 * 1000,
limit: 300,
standardHeaders: 'draft-7',
legacyHeaders: false,
}));
// Stricter limit on the endpoints attackers care about
app.use('/api/v1/auth/login', rateLimit({ windowMs: 15 * 60 * 1000, limit: 10 }));| Risk | Mitigation |
|---|---|
| SQL injection | Parameterised queries or an ORM — never string interpolation |
| Oversized payloads | express.json({ limit }) |
| Brute force | Rate limit auth routes specifically |
| Secrets in the repo | Environment variables, .env git-ignored |
| Vulnerable dependencies | npm audit in CI, Dependabot |
| Crash on unhandled rejection | Log it, then exit and let the supervisor restart cleanly |
| Information disclosure | Generic 500 messages; no stack traces in responses |
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection', reason);
process.exit(1); // fail fast — a half-broken process serves bad data
});Testing the API
Because createApp() is separate from server.js, tests import the app directly with no port binding:
// test/tasks.test.js
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createApp } from '../src/app.js';
let server, baseUrl;
before(async () => {
server = createApp().listen(0); // 0 = any free port
baseUrl = `http://localhost:${server.address().port}`;
});
after(() => server.close());
test('rejects unauthenticated requests', async () => {
const res = await fetch(`${baseUrl}/api/v1/tasks`);
assert.equal(res.status, 401);
});
test('creates a task', async () => {
const res = await fetch(`${baseUrl}/api/v1/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ title: 'Write tests' }),
});
assert.equal(res.status, 201);
const { data } = await res.json();
assert.equal(data.title, 'Write tests');
});node --test
node --test --watchNode’s built-in runner plus the global fetch means you can test a whole API with zero test dependencies. Get the structure, the error handler and the validation layer right early — those three are what make an Express codebase pleasant six months in.