API Design Best Practices: RESTful Patterns That Scale
A poorly designed API becomes technical debt the moment the first client integrates with it. These patterns keep APIs maintainable as they grow.
Resource Naming
# Good — nouns, not verbs
GET /users # List users
GET /users/:id # Get user
POST /users # Create user
PUT /users/:id # Replace user
PATCH /users/:id # Update user fields
DELETE /users/:id # Delete user
# Bad — verbs in URLs
POST /createUser
GET /getUser/:id
POST /deleteUser/:id
Nested Resources
# Posts belonging to a user
GET /users/:userId/posts
POST /users/:userId/posts
GET /users/:userId/posts/:postId
# Limit nesting to 2 levels
# Deep nesting: /users/:id/posts/:id/comments/:id/likes — too deep
# Better: /comments/:id/likes
Consistent Response Shape
// Every endpoint returns the same envelope
interface ApiResponse<T> {
data: T | null;
error: { code: string; message: string } | null;
meta?: { total?: number; page?: number; limit?: number };
}
// Success
res.json({ data: user, error: null });
// Error
res.status(400).json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Email is required' } });
// List with pagination
res.json({ data: users, error: null, meta: { total: 150, page: 2, limit: 20 } });
HTTP Status Codes
200 OK — Success (GET, PUT, PATCH)
201 Created — Resource created (POST)
204 No Content — Success with no body (DELETE)
400 Bad Request — Client validation error
401 Unauthorized — Not authenticated
403 Forbidden — Authenticated but not authorized
404 Not Found — Resource doesn't exist
409 Conflict — Duplicate resource
422 Unprocessable — Valid JSON but invalid data
429 Too Many Requests — Rate limited
500 Internal Error — Server bug
Filtering, Sorting, Pagination
// Query params for filtering/sorting
// GET /posts?status=published&sort=createdAt&order=desc&page=2&limit=20
app.get('/posts', async (req, res) => {
const { status, sort = 'createdAt', order = 'desc', page = 1, limit = 20 } = req.query;
const posts = await prisma.post.findMany({
where: status ? { status: status as string } : undefined,
orderBy: { [sort as string]: order },
skip: (Number(page) - 1) * Number(limit),
take: Number(limit),
});
const total = await prisma.post.count({ where: { status: status as string } });
res.json({
data: posts,
error: null,
meta: { total, page: Number(page), limit: Number(limit) },
});
});
Versioning
# URL versioning (most common)
/api/v1/users
/api/v2/users
# Header versioning
Accept: application/vnd.api+json;version=2
Idempotency Keys
// Prevent duplicate operations on retry
app.post('/payments', async (req, res) => {
const idempotencyKey = req.headers['idempotency-key'] as string;
if (idempotencyKey) {
const existing = await redis.get(`idempotency:${idempotencyKey}`);
if (existing) return res.json(JSON.parse(existing));
}
const result = await processPayment(req.body);
if (idempotencyKey) {
await redis.setex(`idempotency:${idempotencyKey}`, 86400, JSON.stringify(result));
}
res.json(result);
});
API scaffolding ships in the Ship Fast Skill Pack — /api skill generates RESTful endpoints with proper error handling, pagination, and Zod validation. $49 at whoffagents.com.
Top comments (0)