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.js

Splitting 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.

MethodPathMeaningSuccess status
GET/tasksList200
GET/tasks/:idFetch one200
POST/tasksCreate201
PATCH/tasks/:idPartial update200
PUT/tasks/:idFull replace200
DELETE/tasks/:idRemove204

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() after res.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 unused next.

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 }));
RiskMitigation
SQL injectionParameterised queries or an ORM — never string interpolation
Oversized payloadsexpress.json({ limit })
Brute forceRate limit auth routes specifically
Secrets in the repoEnvironment variables, .env git-ignored
Vulnerable dependenciesnpm audit in CI, Dependabot
Crash on unhandled rejectionLog it, then exit and let the supervisor restart cleanly
Information disclosureGeneric 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 --watch

Node’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.