Designing a Clean REST API with Express
· 4 min read · #express #node #rest-api #zod
On this page
An API is a contract with the frontend. When it is consistent, the frontend code becomes boring in the best way: every list looks the same, every error looks the same, and nothing needs special handling.
This post walks through a small Express structure that keeps things consistent as the API grows.
Name resources, not actions
Use nouns for URLs and let the HTTP method be the verb:
GET /posts: list postsGET /posts/:id: get one postPOST /posts: create a postPATCH /posts/:id: update part of a postDELETE /posts/:id: delete a post
Avoid URLs like /getPosts or /deletePost?id=1. The method already says what happens.
Use status codes that mean something
200 OK: successful read or update201 Created: a new resource was created204 No Content: success with nothing to return (often a delete)400 Bad Request: the input is invalid401 Unauthorized: not logged in403 Forbidden: logged in, but not allowed404 Not Found: the resource does not exist409 Conflict: for example, the email is already taken500 Internal Server Error: something broke on the server
Never return 200 with { success: false }. The frontend, caches and monitoring tools all rely on the status code.
One request, four layers
Each layer has one job:
- Router: maps a URL and method to a handler.
- Validation: rejects bad input before any logic runs.
- Controller: reads the request, calls the service, sends the response. HTTP only.
- Service: business rules and database access. It knows nothing about
reqorres.
Validate input with zod
import type { RequestHandler } from "express";
import type { ZodType } from "zod";
export const validate =
(schema: ZodType): RequestHandler =>
(req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: { message: "Invalid input", details: result.error.issues },
});
}
req.body = result.data; // trimmed and typed
next();
};import { z } from "zod";
export const createPostSchema = z.object({
title: z.string().trim().min(3).max(120),
body: z.string().trim().min(1),
tags: z.array(z.string()).max(5).default([]),
});Thin controllers, focused services
import { Router } from "express";
import { validate } from "../middleware/validate";
import { createPostSchema } from "./post.schema";
import * as postService from "./post.service";
export const postRouter = Router();
postRouter.post("/", validate(createPostSchema), async (req, res) => {
const post = await postService.create(req.body);
res.status(201).json({ data: post });
});
postRouter.get("/:id", async (req, res) => {
const post = await postService.findById(req.params.id);
res.json({ data: post });
});import { NotFoundError } from "../errors";
import { db } from "../db";
export async function findById(id: string) {
const post = await db.post.findUnique({ where: { id } });
if (!post) throw new NotFoundError("Post not found");
return post;
}The service throws a meaningful error. It does not decide the status code or the response format; that is the next layer's job.
One central error handler
export class AppError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
export class NotFoundError extends AppError {
constructor(message = "Not found") {
super(404, message);
}
}import express, { type ErrorRequestHandler } from "express";
import { AppError } from "./errors";
import { postRouter } from "./posts/post.routes";
const app = express();
app.use(express.json());
app.use("/posts", postRouter);
// Four arguments tell Express this is the error handler. It must come last.
const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
if (err instanceof AppError) {
return res.status(err.status).json({ error: { message: err.message } });
}
console.error(err);
res.status(500).json({ error: { message: "Something went wrong" } });
};
app.use(errorHandler);Now every error in the API has the same shape: { error: { message } }. The frontend can handle all of them in one place.
Summary
- Nouns in URLs, verbs in HTTP methods.
- Status codes carry meaning; do not hide errors behind
200. - Router → validate → controller → service, each with one job.
- Validate every input with a schema.
- One error handler, one error shape.