Skip to content
All posts
Backend

Designing a Clean REST API with Express

· 4 min read · #express #node #rest-api #zod

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 posts
  • GET /posts/:id: get one post
  • POST /posts: create a post
  • PATCH /posts/:id: update part of a post
  • DELETE /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 update
  • 201 Created: a new resource was created
  • 204 No Content: success with nothing to return (often a delete)
  • 400 Bad Request: the input is invalid
  • 401 Unauthorized: not logged in
  • 403 Forbidden: logged in, but not allowed
  • 404 Not Found: the resource does not exist
  • 409 Conflict: for example, the email is already taken
  • 500 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

Request flow: client to router, validate, controller, service and database, with errors from each step going to an error middleware

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 req or res.

Validate input with zod

src/middleware/validate.ts
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();
  };
src/posts/post.schema.ts
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

src/posts/post.routes.ts
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 });
});
src/posts/post.service.ts
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

src/errors.ts
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);
  }
}
src/app.ts
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.