REST API Design Principles
REST API Design Principles
A REST API is only as good as its design. The technical implementation is secondary — what matters is whether other developers (or your future self) can use it without confusion. Good API design is about predictability, consistency, and being explicit about intent.
REST Fundamentals
REST (Representational State Transfer) uses HTTP verbs and URLs to represent operations on resources:
| Method | URL | Action |
|---|---|---|
GET | /api/courses | List all courses |
GET | /api/courses/42 | Get course #42 |
POST | /api/courses | Create a new course |
PUT | /api/courses/42 | Replace course #42 entirely |
PATCH | /api/courses/42 | Update specific fields of course #42 |
DELETE | /api/courses/42 | Delete course #42 |
The URL describes the resource. The method describes the action. Clear separation of concerns.
URL Naming Conventions
Use nouns, not verbs. The HTTP method is already the verb:
❌ Bad — verbs in URLs
GET /getCourses
POST /createCourse
DELETE /deleteCourse/42
✅ Good — nouns in URLs
GET /api/courses
POST /api/courses
DELETE /api/courses/42
Use plural nouns consistently:
/api/courses not /api/course
/api/users not /api/user
/api/enrollments not /api/enrollment
Nested resources express relationships:
GET /api/courses/42/lessons → lessons for course 42
GET /api/courses/42/lessons/5 → lesson 5 of course 42
POST /api/courses/42/lessons → create a lesson in course 42
DELETE /api/courses/42/lessons/5 → delete lesson 5 of course 42
Keep nesting shallow — more than two levels deep becomes hard to maintain.
HTTP Status Codes
Use the right status code. It communicates the result without the caller having to parse the response body:
2xx — Success
200 OK → Successful GET, PATCH, PUT
201 Created → Successful POST (include Location header with new resource URL)
204 No Content → Successful DELETE (no body)
3xx — Redirect
301 Moved Permanently
302 Found (temporary redirect)
4xx — Client Error (the caller did something wrong)
400 Bad Request → Invalid input, malformed JSON, missing required fields
401 Unauthorized → Not authenticated (no token / invalid token)
403 Forbidden → Authenticated but not allowed to do this
404 Not Found → Resource doesn't exist
409 Conflict → Duplicate entry (email already registered, slug already exists)
422 Unprocessable → Validation failed (valid JSON, but field values are wrong)
429 Too Many Requests → Rate limited
5xx — Server Error (something went wrong on your end)
500 Internal Server Error → Unexpected crash
503 Service Unavailable → Database down, maintenance mode
Consistent Error Responses
Always return errors in the same format:
// src/lib/errors.ts
export function errorResponse(message: string, statusCode: number, details?: Record<string, string>) {
return {
error: {
message,
statusCode,
...(details && { details }),
}
};
}
// 400 Bad Request
{
"error": {
"message": "Validation failed",
"statusCode": 400,
"details": {
"email": "Valid email required",
"password": "Must be at least 8 characters"
}
}
}
// 404 Not Found
{
"error": {
"message": "Course not found",
"statusCode": 404
}
}
Don't leak internal error messages in production — "Column 'user_id' cannot be null" tells attackers about your schema. Return generic messages for 5xx errors and log the details server-side.
Pagination
Never return thousands of records without pagination:
// GET /api/courses?page=2&limit=20
app.get("/api/courses", async (req, res) => {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const limit = Math.min(100, parseInt(req.query.limit as string) || 20);
const skip = (page - 1) * limit;
const [courses, total] = await Promise.all([
db.course.findMany({ skip, take: limit }),
db.course.count(),
]);
res.json({
data: courses,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
hasNext: page * limit < total,
hasPrev: page > 1,
}
});
});
Response:
{
"data": [ ... ],
"pagination": {
"page": 2,
"limit": 20,
"total": 147,
"pages": 8,
"hasNext": true,
"hasPrev": true
}
}
Filtering and Sorting
// GET /api/courses?category=react&sort=price&order=asc&search=hooks
app.get("/api/courses", async (req, res) => {
const { category, search, sort = "createdAt", order = "desc" } = req.query;
const where: any = {};
if (category) where.category = category;
if (search) where.title = { contains: search, mode: "insensitive" };
const allowedSorts = ["price", "createdAt", "title", "rating"];
const sortField = allowedSorts.includes(sort as string) ? sort : "createdAt";
const courses = await db.course.findMany({
where,
orderBy: { [sortField as string]: order === "asc" ? "asc" : "desc" },
});
res.json({ data: courses });
});
Whitelist allowed sort fields — never pass user-controlled values directly to orderBy. That's a potential injection vector.
Versioning
Version your API so you can introduce breaking changes without breaking existing clients:
/api/v1/courses → Current stable version
/api/v2/courses → New version with breaking changes
import v1Router from "./routes/v1";
import v2Router from "./routes/v2";
app.use("/api/v1", v1Router);
app.use("/api/v2", v2Router);
Commit to v1 forever. When you need to break something, release v2 and give clients a migration window before deprecating v1.
Request Validation
Validate all input before touching your database. Use zod for TypeScript-first validation:
npm install zod
import { z } from "zod";
const CreateCourseSchema = z.object({
title: z.string().min(3).max(200),
description: z.string().min(10).max(2000),
price: z.number().min(0).max(10000),
category: z.enum(["react", "python", "javascript", "ai"]),
});
app.post("/api/courses", async (req, res) => {
const result = CreateCourseSchema.safeParse(req.body);
if (!result.success) {
const details = Object.fromEntries(
result.error.issues.map(i => [i.path.join("."), i.message])
);
return res.status(400).json({
error: { message: "Validation failed", statusCode: 400, details }
});
}
const course = await db.course.create({ data: result.data });
res.status(201).json(course);
});
Rate Limiting
Protect your API from abuse:
npm install express-rate-limit
import rateLimit from "express-rate-limit";
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true,
message: { error: "Too many requests, please try again later." },
});
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // Stricter limit on login attempts
message: { error: "Too many login attempts." },
});
app.use("/api/", apiLimiter);
app.use("/api/auth/login", authLimiter);
Idempotency
Design operations with idempotency in mind:
GETis always idempotent — same request, same resultDELETEis idempotent — deleting something twice should both return 2xx (or the second returns 404)PUTis idempotent — replacing a resource with the same data twice gives the same resultPOSTis not idempotent — calling it twice creates two records
For operations like payments that must not run twice, accept an idempotency key:
app.post("/api/payments", async (req, res) => {
const idempotencyKey = req.headers["idempotency-key"] as string;
if (idempotencyKey) {
const existing = await db.payment.findUnique({ where: { idempotencyKey } });
if (existing) return res.json(existing); // Return cached result
}
const payment = await processPayment(req.body);
if (idempotencyKey) {
await db.payment.update({ where: { id: payment.id }, data: { idempotencyKey } });
}
res.status(201).json(payment);
});
Next lesson: File uploads with Node.js — handling images, documents, and other uploaded files.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises