Project: Build a REST API with Express
A REST API is the bread-and-butter Node.js project: it forces you to combine routing, validation, persistence, error handling, and authentication into one coherent, layered service. In this project you’ll build a small “tasks” API from scratch with Express 5 and Postgres, organised so that each concern lives in its own layer — routes, controllers, services, and a data access layer. The result is a structure you can scale to dozens of endpoints without it collapsing into a single 2,000-line file. Work through the milestones at the end to build it incrementally.
Project structure
A flat index.js is fine for a demo but quickly becomes unmaintainable. The convention that scales is to separate transport (Express routing) from business logic (services) from persistence (the database layer). Each layer only knows about the one beneath it.
tasks-api/
├── src/
│ ├── app.js # Express app wiring (no listen)
│ ├── server.js # process entry — starts the HTTP server
│ ├── db.js # database pool
│ ├── routes/
│ │ └── tasks.routes.js
│ ├── controllers/
│ │ └── tasks.controller.js
│ ├── services/
│ │ └── tasks.service.js
│ ├── middleware/
│ │ ├── auth.js
│ │ ├── validate.js
│ │ └── error.js
│ └── schemas/
│ └── task.schema.js
├── .env
└── package.json
Use ES modules throughout ("type": "module" in package.json). Install the dependencies:
npm init -y
npm install express pg zod jsonwebtoken
npm install --save-dev nodemon
The database layer
Centralise the connection in a single pooled module so every part of the app shares one set of connections. Use parameterised queries ($1, $2) — never string interpolation — to stay safe from SQL injection.
// src/db.js
import pg from "pg";
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
});
export const query = (text, params) => pool.query(text, params);
export default pool;
Tip: Keep
pool.end()out of request handlers. Pools are long-lived; you only close them on graceful shutdown (SIGTERM).
Routes, controllers, and services
Routes map URLs to handlers and nothing more. Controllers translate HTTP into plain function calls and shape the response. Services hold the actual logic and talk to the database — they have no idea Express exists, which makes them trivial to unit test.
// src/services/tasks.service.js
import { query } from "../db.js";
export async function listTasks(userId) {
const { rows } = await query(
"SELECT id, title, done FROM tasks WHERE user_id = $1 ORDER BY id",
[userId],
);
return rows;
}
export async function createTask(userId, { title }) {
const { rows } = await query(
"INSERT INTO tasks (user_id, title, done) VALUES ($1, $2, false) RETURNING id, title, done",
[userId, title],
);
return rows[0];
}
export async function deleteTask(userId, id) {
const { rowCount } = await query(
"DELETE FROM tasks WHERE id = $1 AND user_id = $2",
[id, userId],
);
return rowCount > 0;
}
// src/controllers/tasks.controller.js
import * as service from "../services/tasks.service.js";
export async function index(req, res) {
const tasks = await service.listTasks(req.userId);
res.json(tasks);
}
export async function create(req, res) {
const task = await service.createTask(req.userId, req.body);
res.status(201).json(task);
}
export async function remove(req, res, next) {
const ok = await service.deleteTask(req.userId, req.params.id);
if (!ok) return next({ status: 404, message: "Task not found" });
res.status(204).end();
}
// src/routes/tasks.routes.js
import { Router } from "express";
import * as ctrl from "../controllers/tasks.controller.js";
import { auth } from "../middleware/auth.js";
import { validate } from "../middleware/validate.js";
import { taskSchema } from "../schemas/task.schema.js";
const router = Router();
router.use(auth);
router.get("/", ctrl.index);
router.post("/", validate(taskSchema), ctrl.create);
router.delete("/:id", ctrl.remove);
export default router;
Validation
Never trust the request body. Define a schema with Zod and run it in a reusable middleware so controllers receive only clean, typed data.
// src/schemas/task.schema.js
import { z } from "zod";
export const taskSchema = z.object({
title: z.string().trim().min(1).max(200),
});
// src/middleware/validate.js
export const validate = (schema) => (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return next({ status: 400, message: "Validation failed", details: result.error.issues });
}
req.body = result.data;
next();
};
Authentication
Protect routes with a JWT-based middleware. The client sends Authorization: Bearer <token>; the middleware verifies it and attaches req.userId. Token issuing (login) is covered in the auth project linked below.
// src/middleware/auth.js
import jwt from "jsonwebtoken";
export function auth(req, res, next) {
const header = req.headers.authorization ?? "";
const token = header.startsWith("Bearer ") ? header.slice(7) : null;
if (!token) return next({ status: 401, message: "Missing token" });
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.userId = payload.sub;
next();
} catch {
next({ status: 401, message: "Invalid token" });
}
}
Centralised error handling
Express 5 forwards rejected async handlers to error middleware automatically, so you can throw (or next(err)) anywhere. Define one error handler last — it must take four arguments — and shape every failure into a consistent JSON body.
// src/middleware/error.js
export function errorHandler(err, req, res, next) {
const status = err.status ?? 500;
if (status === 500) console.error(err);
res.status(status).json({
error: err.message ?? "Internal Server Error",
details: err.details,
});
}
// src/app.js
import express from "express";
import tasksRoutes from "./routes/tasks.routes.js";
import { errorHandler } from "./middleware/error.js";
export const app = express();
app.use(express.json());
app.use("/tasks", tasksRoutes);
app.use(errorHandler);
// src/server.js
import { app } from "./app.js";
const port = process.env.PORT ?? 3000;
const server = app.listen(port, () => console.log(`API on http://localhost:${port}`));
process.on("SIGTERM", () => server.close());
A request to a protected route returns clean JSON:
Output:
$ curl -s localhost:3000/tasks -H "Authorization: Bearer $TOKEN"
[{"id":1,"title":"Write docs","done":false}]
Postgres vs Mongo
Either store works; pick based on your data shape and team familiarity.
| Concern | Postgres (pg) | MongoDB (mongodb / Mongoose) |
|---|---|---|
| Data model | Relational, fixed schema | Document, flexible schema |
| Joins / relations | Native, strong | Manual or $lookup |
| Transactions | First-class, multi-row | Supported, less central |
| Best fit | Structured, related entities | Nested/variable documents |
Milestones
- Skeleton —
app.js+server.js, aGET /healthroute returning{ status: "ok" }. - Database — wire up the pool, create the
taskstable, implementlistTasks. - CRUD — add create, read-one, update, and delete through the service layer.
- Validation — add Zod schemas and the
validatemiddleware on write routes. - Auth — add the JWT middleware and scope every query by
req.userId. - Polish — centralised error handler, graceful shutdown, and a
.envfor secrets.
Best Practices
- Keep services free of
req/res; pass plain arguments so logic stays testable and reusable. - Always use parameterised queries — never interpolate user input into SQL.
- Validate and sanitise at the edge so controllers and services trust their inputs.
- Return correct status codes:
201for created,204for empty success,4xxfor client errors. - Funnel all errors through one error middleware for consistent response shapes.
- Store secrets (
DATABASE_URL,JWT_SECRET) in environment variables, never in code. - Scope every query to the authenticated user to prevent cross-tenant data leaks.