API Design
Learn to design clean, consistent REST APIs that other developers will love using.
What Makes an API Good?#
Imagine using a TV remote where the volume button changes channels, the power button opens Netflix, and you need a different remote for each room. Frustrating, right?
Bad APIs feel the same way. Good APIs are predictable, consistent, and obvious. When you understand one endpoint, you understand them all.
This article teaches you to design APIs that developers actually enjoy using.
REST: The Language of Web APIs#
REST isn't a technology - it's a set of principles for designing web APIs. The core idea: everything is a resource, and you interact with resources using standard HTTP methods.
Think of resources as nouns: users, posts, comments, products. Think of HTTP methods as verbs: get, create, update, delete.
GET /users → Get all users
GET /users/42 → Get user #42
POST /users → Create a new user
PUT /users/42 → Replace user #42
PATCH /users/42 → Update user #42
DELETE /users/42 → Delete user #42
That's it. If you understand this pattern, you can guess how any well-designed API works.
Designing Good URLs#
Your URLs should read like sentences. Someone should look at a URL and know exactly what it does.
Use Nouns, Not Verbs#
# GOOD - the HTTP method tells you the action
GET /users
POST /users
DELETE /users/42
# BAD - verbs in URLs
GET /getUsers
POST /createUser
DELETE /deleteUser/42
The HTTP method (GET, POST, DELETE) already tells you the action. Putting verbs in URLs is redundant.
Use Plural Nouns#
# GOOD - consistent plural nouns
/users
/users/42
/posts
/posts/123
# BAD - mixing singular and plural
/user
/user/42
/posts
/post/123
Pick one and stick with it. Plural is the convention.
Nest for Relationships#
When resources belong to other resources, nest them:
# User #42's posts
GET /users/42/posts
# Post #7 belonging to user #42
GET /users/42/posts/7
# Comments on post #7
GET /posts/7/comments
But don't go too deep - two levels is usually enough:
# TOO DEEP - confusing and hard to use
GET /users/42/posts/7/comments/99/likes/1
# BETTER - flatten when it gets complex
GET /comments/99
GET /likes?comment_id=99
Query Parameters: Filtering, Sorting, Pagination#
Use query parameters for optional things that modify what you get back:
# Filtering
GET /users?status=active
GET /users?role=admin&status=active
# Sorting
GET /posts?sort=created_at # Ascending
GET /posts?sort=-created_at # Descending (prefix with -)
# Pagination
GET /users?page=2&limit=20
# Searching
GET /products?search=laptop
# Combining them
GET /products?category=electronics&sort=-price&page=1&limit=10
Implementing Pagination#
Every list endpoint should be paginated. Don't return 10,000 results at once.
app.get('/api/users', async (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const skip = (page - 1) * limit;
const [users, total] = await Promise.all([
User.find().skip(skip).limit(limit),
User.countDocuments()
]);
res.json({
data: users,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
});
});
Response:
{
"data": [...],
"pagination": {
"page": 1,
"limit": 20,
"total": 156,
"pages": 8
}
}
Consistent Response Format#
Every response should follow the same structure. Developers shouldn't guess where the data is.
Success Responses#
// Single item
{
"data": {
"id": "42",
"name": "Sarah",
"email": "sarah@example.com"
}
}
// List of items
{
"data": [
{ "id": "1", "name": "Alice" },
{ "id": "2", "name": "Bob" }
],
"pagination": {
"page": 1,
"limit": 20,
"total": 2,
"pages": 1
}
}
Error Responses#
// Simple error
{
"error": {
"code": "NOT_FOUND",
"message": "User not found"
}
}
// Validation error with details
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input",
"details": [
{ "field": "email", "message": "Invalid email format" },
{ "field": "password", "message": "Must be at least 8 characters" }
]
}
}
Helper Functions#
Keep your responses consistent with helper functions:
// utils/response.js
export function success(res, data, status = 200) {
res.status(status).json({ data });
}
export function created(res, data) {
res.status(201).json({ data });
}
export function paginated(res, data, pagination) {
res.json({ data, pagination });
}
export function noContent(res) {
res.status(204).send();
}
// Usage
app.get('/api/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
success(res, user);
});
app.post('/api/users', async (req, res) => {
const user = await User.create(req.body);
created(res, user);
});
app.delete('/api/users/:id', async (req, res) => {
await User.findByIdAndDelete(req.params.id);
noContent(res);
});
Input Validation#
Never trust user input. Ever. Validate everything.
Using Zod (Recommended)#
import { z } from 'zod';
// Define what valid data looks like
const createUserSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
password: z.string().min(8),
role: z.enum(['user', 'admin']).optional().default('user')
});
// Validation middleware
function validate(schema) {
return (req, res, next) => {
try {
// This throws if validation fails
req.body = schema.parse(req.body);
next();
} catch (error) {
res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: error.errors.map(e => ({
field: e.path.join('.'),
message: e.message
}))
}
});
}
};
}
// Use it
app.post('/api/users', validate(createUserSchema), async (req, res) => {
// req.body is guaranteed to be valid here
const user = await User.create(req.body);
created(res, user);
});
Different Schemas for Different Operations#
// Creating a user - all fields required
const createUserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
password: z.string().min(8)
});
// Updating a user - all fields optional
const updateUserSchema = z.object({
name: z.string().min(2).optional(),
email: z.string().email().optional()
});
// Or simply:
const updateUserSchema = createUserSchema.partial();
HTTP Status Codes That Matter#
You don't need to memorize all status codes. These 8 cover 99% of cases:
| Code | Name | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST that created something |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Invalid input, validation failed |
| 401 | Unauthorized | Not logged in, bad/missing token |
| 403 | Forbidden | Logged in but not allowed |
| 404 | Not Found | Resource doesn't exist |
| 500 | Internal Server Error | Bug in your code |
400 vs 404
Use 400 when the request is malformed (bad JSON, missing fields). Use 404 when the request is valid but the resource doesn't exist.
Versioning Your API#
APIs evolve. When you make breaking changes, old apps might break. Versioning solves this.
// URL versioning (most common)
app.use('/api/v1', v1Routes);
app.use('/api/v2', v2Routes);
// Clients request:
// GET /api/v1/users (old format)
// GET /api/v2/users (new format)
When do you need a new version?
- Removing a field
- Changing a field's type
- Changing response structure
- Changing required fields
When you DON'T need a new version:
- Adding a new field (backwards compatible)
- Adding a new endpoint
- Fixing bugs
Putting It All Together#
Here's a well-designed API for a blog:
import express from 'express';
import { z } from 'zod';
const app = express();
app.use(express.json());
// Schemas
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
published: z.boolean().default(false)
});
const updatePostSchema = createPostSchema.partial();
const paginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20)
});
// Middleware
function validate(schema) {
return (req, res, next) => {
try {
req.body = schema.parse(req.body);
next();
} catch (error) {
res.status(400).json({
error: { code: 'VALIDATION_ERROR', message: error.errors[0].message }
});
}
};
}
// Routes
// GET /api/posts?page=1&limit=20
app.get('/api/posts', async (req, res) => {
const { page, limit } = paginationSchema.parse(req.query);
const skip = (page - 1) * limit;
const [posts, total] = await Promise.all([
Post.find({ published: true }).skip(skip).limit(limit),
Post.countDocuments({ published: true })
]);
res.json({
data: posts,
pagination: { page, limit, total, pages: Math.ceil(total / limit) }
});
});
// GET /api/posts/:id
app.get('/api/posts/:id', async (req, res) => {
const post = await Post.findById(req.params.id);
if (!post) {
return res.status(404).json({
error: { code: 'NOT_FOUND', message: 'Post not found' }
});
}
res.json({ data: post });
});
// POST /api/posts
app.post('/api/posts', authenticate, validate(createPostSchema), async (req, res) => {
const post = await Post.create({
...req.body,
author: req.user.id
});
res.status(201).json({ data: post });
});
// PATCH /api/posts/:id
app.patch('/api/posts/:id', authenticate, validate(updatePostSchema), async (req, res) => {
const post = await Post.findOneAndUpdate(
{ _id: req.params.id, author: req.user.id },
req.body,
{ new: true }
);
if (!post) {
return res.status(404).json({
error: { code: 'NOT_FOUND', message: 'Post not found' }
});
}
res.json({ data: post });
});
// DELETE /api/posts/:id
app.delete('/api/posts/:id', authenticate, async (req, res) => {
const result = await Post.deleteOne({ _id: req.params.id, author: req.user.id });
if (result.deletedCount === 0) {
return res.status(404).json({
error: { code: 'NOT_FOUND', message: 'Post not found' }
});
}
res.status(204).send();
});
Key Takeaways#
Good API design is about consistency and predictability:
- Resources as nouns -
/users,/posts,/comments - HTTP methods as verbs - GET reads, POST creates, DELETE removes
- Consistent responses - Always wrap in
{ data: ... }or{ error: ... } - Validate input - Never trust user data, use Zod or similar
- Proper status codes - 200, 201, 400, 401, 404, 500 cover most cases
- Pagination - Never return unlimited results
The Test
A well-designed API passes this test: if a developer knows how to get a list of users, they can guess how to get a list of anything else. Consistency is everything.
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.