REST APIs are not glamorous in 2026, but they power the majority of the web's backend communication. The fundamentals have not changed — HTTP verbs, resources, status codes — but the tooling, type safety expectations, and security requirements have. This guide builds a production-quality REST API from zero.
What changed in 2026
- TypeScript is the baseline, not an option. Untyped Node.js API code is a code-smell in any team that has more than one developer.
- Zod for runtime validation is the community default;
express-validator and joi have largely been displaced.
- OpenAPI 3.1 spec-first development is growing — generate the spec first, generate clients and server stubs from it.
- Hono is a serious alternative to Express for edge-native APIs; the patterns below apply to both.
- JWT + JWKS is the standard auth pattern for APIs consumed by frontends and third parties.
REST design fundamentals
GET /api/v1/tasks → list tasks
POST /api/v1/tasks → create a task
GET /api/v1/tasks/:id → get one task
PATCH /api/v1/tasks/:id → partially update a task
DELETE /api/v1/tasks/:id → delete a task
Rules:
- URLs are nouns, HTTP methods are verbs.
- Use
PATCH for partial updates, PUT for full replacement.
- Never use verbs in URLs (
/api/createTask is wrong).
- Nest resources sparingly;
/tasks/:id/comments is fine, /users/:id/teams/:id/projects/:id/tasks is not.
Project structure
src/
app.ts # Express app (no listen call — testable)
server.ts # listen here
routes/
tasks.ts
controllers/
tasks.controller.ts
services/
tasks.service.ts
middleware/
authenticate.ts
validate.ts
errorHandler.ts
db/
index.ts
schema.ts
Setting up the stack
npm init -y
npm install express zod jsonwebtoken
npm install -D typescript @types/express @types/node tsx vitest supertest @types/supertest
A complete route with validation
// src/routes/tasks.ts
import { Router } from "express";
import { z } from "zod";
import { validate } from "../middleware/validate";
import * as ctrl from "../controllers/tasks.controller";
const router = Router();
const CreateTask = z.object({
body: z.object({
title: z.string().min(1).max(200),
priority: z.enum(["low", "medium", "high"]).default("medium"),
}),
});
const UpdateTask = z.object({
params: z.object({ id: z.coerce.number().int().positive() }),
body: z.object({
title: z.string().min(1).max(200).optional(),
status: z.enum(["todo", "in_progress", "done"]).optional(),
}),
});
router.get("/", ctrl.listTasks);
router.post("/", validate(CreateTask), ctrl.createTask);
router.patch("/:id", validate(UpdateTask), ctrl.updateTask);
router.delete("/:id", ctrl.deleteTask);
export default router;
// src/middleware/validate.ts
import { Request, Response, NextFunction } from "express";
import { ZodSchema, ZodError } from "zod";
export const validate =
(schema: ZodSchema) =>
(req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse({
body: req.body,
params: req.params,
query: req.query,
});
if (!result.success) {
res.status(400).json({
error: "Validation failed",
code: "VALIDATION_ERROR",
details: result.error.flatten(),
});
return;
}
Object.assign(req, result.data);
next();
};
Error envelope convention
Every error response must have the same shape. Clients can rely on it.
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from "express";
export function errorHandler(
err: Error & { statusCode?: number; code?: string },
_req: Request,
res: Response,
_next: NextFunction
) {
const status = err.statusCode ?? 500;
res.status(status).json({
error: err.message,
code: err.code ?? "INTERNAL_ERROR",
});
}
Authentication pattern
Use JWTs issued by a managed auth provider (Clerk, Auth0, Supabase Auth). Verify the token on every protected route:
// src/middleware/authenticate.ts
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
export function authenticate(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.split(" ")[1];
if (!token) {
res.status(401).json({ error: "Unauthorized", code: "NO_TOKEN" });
return;
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!);
(req as any).user = payload;
next();
} catch {
res.status(401).json({ error: "Invalid token", code: "INVALID_TOKEN" });
}
}
HTTP status code cheat sheet
| Situation |
Status |
| Created successfully |
201 |
| No content (DELETE) |
204 |
| Bad input / validation |
400 |
| Not authenticated |
401 |
| Authenticated but forbidden |
403 |
| Resource not found |
404 |
| Conflict (duplicate) |
409 |
| Rate limited |
429 |
| Server error |
500 |
How to pick between REST and alternatives
| API style |
Best for |
Avoid for |
| REST |
Standard CRUD, wide clients |
Real-time, graph-shaped data |
| GraphQL |
Graph-heavy data, flexible queries |
Simple CRUD |
| tRPC |
Full-stack TypeScript monorepos |
Public third-party APIs |
| gRPC |
Service-to-service, performance |
Browser clients |
REST is the right default when building a public API or serving clients you do not control.
Common mistakes
Returning 200 for errors. Clients rely on HTTP status codes to route error handling. A 200 with { success: false } breaks every HTTP client library's default behavior.
No pagination on list endpoints. Return { data: [], meta: { total, page, limit } } from day one. Adding pagination to an unpaginated API is a breaking change.
Skipping rate limiting. Add express-rate-limit or an upstream API gateway rate limit before any route is public. A single user can knock over an unprotected API.
Exposing internal IDs everywhere. Sequential integer IDs leak record counts and enable enumeration attacks. Use UUIDs or ULIDs for resources exposed in URLs.
What to skip
- Custom session management — JWT with a managed provider is more secure and less code.
- SOAP or XML responses — no new API should be built on SOAP in 2026.
- Over-nesting routes — more than two levels of resource nesting (
/a/:id/b/:id/c) is a design smell; flatten or use query params.
FAQ
Should I use REST or GraphQL in 2026?
REST for public APIs and simple CRUD. GraphQL when your data is genuinely graph-shaped or your clients need flexible query shapes without over-fetching.
How do I document my REST API?
Generate an OpenAPI 3.1 spec. Use Scalar or Swagger UI to render it. Libraries like zod-openapi can generate the spec from your Zod schemas automatically.
How do I handle file uploads in a REST API?
Accept multipart/form-data with multer. Stream directly to S3-compatible storage; never buffer large files in memory on the server.
What is the right way to handle pagination?
Cursor-based pagination (return a nextCursor) scales better than offset-based for large or frequently updated datasets. Offset is fine for small, stable datasets.
Where to go next