REST API Design Best Practices Every Developer Should Know
The Decisions You Make When Designing an API Are Permanent — Here's How to Get Them Right the First Time
Senior Developer

Why API Design Is Worth Getting Right Early
An API is a contract. Once developers — internal or external — start building against it, every endpoint URL, every field name, every response structure becomes a commitment. Change them and you break clients. Add a deprecation cycle and you're maintaining two versions of the same thing for months.
The cost of bad API design compounds over time. A confusing endpoint naming pattern means every new developer has to learn the inconsistency. A poorly structured error response means every client has to write custom parsing logic. A missing pagination mechanism means you're forced to add it later as a breaking change.
Getting the fundamentals right at the start costs nothing extra. Cleaning them up later is expensive.
Use Resources and HTTP Methods Correctly
REST is built around the concept of resources — things in your system — and HTTP methods that describe the action being performed on them. The most common mistake is treating endpoints like RPC function calls instead:
# 🚫 RPC-style — using verbs in URLs
POST /createUser
POST /getUser
POST /updateUserEmail
POST /deleteUser
# ✅ REST-style — using nouns (resources) and HTTP methods
POST /users # Create a user
GET /users/:id # Retrieve a user
PATCH /users/:id # Update a user
DELETE /users/:id # Delete a userThe URL identifies the resource. The HTTP method describes what you're doing to it. When you follow this, the API becomes self-documenting — a developer can infer what DELETE /posts/:id does without reading documentation.
The HTTP methods and their semantics:
Method | Purpose | Idempotent? | Has Body? |
|---|---|---|---|
| Retrieve a resource | Yes | No |
| Create a new resource | No | Yes |
| Replace a resource entirely | Yes | Yes |
| Partially update a resource | No | Yes |
| Remove a resource | Yes | No |
Idempotent means calling the same request multiple times produces the same result. DELETE /users/123 called twice still results in user 123 being gone — the second call is a no-op (or returns 404). POST /users called twice creates two users. This distinction matters for retry logic in clients.
Naming Resources: Conventions That Actually Matter
A few rules that keep your API consistent and predictable:
Use plural nouns for collections:
/users ← collection of users
/users/42 ← specific user
/posts ← collection of posts
/posts/7 ← specific postNest resources to express relationships, but don't go deep:
GET /users/42/posts # Posts belonging to user 42 ✅
GET /users/42/posts/7 # Specific post of user 42 ✅
GET /users/42/posts/7/comments/3/likes # Too deep — hard to maintain ❌When nesting gets beyond two levels, consider flattening. Instead of /users/42/posts/7/comments, you might just expose /comments?postId=7. Flat is often cleaner.
Use kebab-case for multi-word resource names:
/blog-posts ✅
/blog_posts ❌ (underscores look odd in URLs)
/blogPosts ❌ (camelCase in URLs is unusual)Use camelCase for JSON field names:
{
"firstName": "Jane",
"lastName": "Doe",
"createdAt": "2024-03-15T10:30:00Z"
}Pick a convention and apply it everywhere without exceptions. Inconsistency in naming is the thing that frustrates API consumers most.
Use HTTP Status Codes Meaningfully
HTTP status codes are a communication layer between your API and its clients. When used correctly, clients can handle responses intelligently without parsing error messages. When every response returns 200 OK with a success flag in the body, you've thrown that communication channel away.
The codes you'll use most often:
2xx — Success
200 OK— General success for GET, PUT, PATCH201 Created— A new resource was created (POST). Include aLocationheader pointing to the new resource.204 No Content— Success with no response body (DELETE, or PUT when not returning the updated resource)
4xx — Client errors
400 Bad Request— The request is malformed, missing required fields, or fails validation401 Unauthorized— The request lacks valid authentication credentials403 Forbidden— Authenticated but not authorized to perform this action404 Not Found— The resource doesn't exist409 Conflict— The request conflicts with current state (e.g., email already taken)422 Unprocessable Entity— The request is well-formed but semantically invalid (validation errors)429 Too Many Requests— Rate limit exceeded
5xx — Server errors
500 Internal Server Error— Something unexpected went wrong on the server503 Service Unavailable— The server is temporarily down or overloaded
A common source of confusion: 401 vs 403. Use 401 when the user isn't authenticated at all (no token, invalid token). Use 403 when they're authenticated but don't have permission for this specific action. They mean different things and clients handle them differently.
Design Consistent, Useful Error Responses
A status code tells the client that something went wrong. The response body tells them what went wrong and ideally how to fix it. Most APIs do this badly — they return either no body, or an inconsistent structure that varies by endpoint.
Define a standard error shape and use it everywhere:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The request contains invalid fields.",
"details": [
{
"field": "email",
"message": "Must be a valid email address."
},
{
"field": "password",
"message": "Must be at least 8 characters."
}
],
"requestId": "req_8f3k2j9x"
}
}The code field is a machine-readable string constant that clients can switch on — more reliable than parsing the human-readable message. The details array gives field-level validation feedback. The requestId ties the error to a specific request in your logs, which makes debugging much faster when a user reports an issue.
In Express/Node.js, centralize error handling in a single middleware rather than handling errors differently per route:
// Centralized error handler — registered last
app.use((err, req, res, next) => {
const status = err.status ?? 500;
const code = err.code ?? 'INTERNAL_ERROR';
// Log server errors, not client errors
if (status >= 500) {
logger.error(err, { requestId: req.id });
}
res.status(status).json({
error: {
code,
message: err.message ?? 'An unexpected error occurred.',
details: err.details ?? [],
requestId: req.id,
},
});
});Pagination: Never Return Unbounded Collections
A GET /users endpoint that returns every user in your database is a bug waiting to happen. As your data grows, response times increase, memory usage spikes, and eventually the request just fails. Pagination is not an optimization — it's a correctness requirement for any collection endpoint.
Cursor-based pagination is the modern standard, especially for large or frequently changing datasets:
GET /posts?limit=20&cursor=eyJpZCI6MTAwfQ==
Response:
{
"data": [...],
"pagination": {
"hasNextPage": true,
"nextCursor": "eyJpZCI6MTIwfQ==",
"limit": 20
}
}The cursor encodes a position in the dataset (typically a base64-encoded ID or timestamp). Clients pass the nextCursor from one response as the cursor in the next request. This approach is stable — inserting or deleting records doesn't shift results the way offset-based pagination does.
Offset-based pagination is simpler to implement and works well for smaller, stable datasets:
GET /posts?page=3&limit=20
Response:
{
"data": [...],
"pagination": {
"page": 3,
"limit": 20,
"total": 847,
"totalPages": 43
}
}The problem with offset-based: if a new record is inserted while a client is paginating, they'll see a duplicate item or skip one. For most admin dashboards or report views this is acceptable. For real-time feeds it isn't.
Always enforce a maximum limit regardless of what the client requests, and use a sensible default:
const limit = Math.min(parseInt(req.query.limit) || 20, 100);API Versioning: Plan for Change
Your API will change. New fields will be added. Endpoints will be restructured. Business logic will evolve. The question isn't whether you'll need to make breaking changes — it's whether you have a mechanism to make them without breaking existing clients.
The most common and pragmatic approach is URL versioning:
/v1/users
/v2/usersIt's explicit, easy to route, and immediately visible in logs and documentation. When you release v2, v1 continues running until you deprecate and sunset it.
Alternative approaches: header versioning (API-Version: 2) is cleaner in URLs but harder to test in a browser and easy to forget. Query parameter versioning (/users?version=2) pollutes your URLs with non-resource concerns.
A few versioning principles worth adopting:
Never make breaking changes within a version. Adding new optional fields is fine. Removing fields, renaming them, or changing their types is a breaking change that needs a new version.
Announce deprecations early. Add a
Deprecationheader to responses from old versions, and document the sunset date clearly.Keep version support windows predictable. Tell clients upfront: "v1 will be supported for 12 months after v2 launches." Stick to it.
Filtering, Sorting, and Searching
Collection endpoints need to be queryable. A predictable, consistent pattern for query parameters makes this easy to learn:
# Filtering — use field names as query params
GET /posts?status=published&authorId=42
# Sorting — field name with optional direction prefix
GET /posts?sort=-createdAt # Descending (most recent first)
GET /posts?sort=title # Ascending
GET /posts?sort=-createdAt,title # Multiple fields
# Full-text search
GET /posts?q=docker+best+practices
# Combined
GET /posts?status=published&sort=-createdAt&limit=20&cursor=abc123For complex filter logic (AND, OR, ranges), some APIs use a structured filter syntax:
GET /products?filter[price][gte]=10&filter[price][lte]=100
GET /products?filter[category]=electronics&filter[inStock]=trueWhatever convention you choose, document it clearly and apply it consistently across every collection endpoint. Inconsistency here is what makes APIs feel unpleasant to use.
Return the Right Thing After Mutations
What should a POST /users or PATCH /users/42 return? The most useful thing you can return is the updated resource — not just a success message, not just the ID.
// POST /users — create a user
res.status(201)
.header('Location', `/users/${newUser.id}`)
.json({ data: newUser });
// PATCH /users/:id — update a user
res.status(200).json({ data: updatedUser });
// DELETE /users/:id — delete a user
res.status(204).send(); // No body neededReturning the full resource after a mutation saves the client an extra round-trip. Instead of POST → 201 → GET /users/newId → 200, the client gets everything it needs in one request. For DELETE, there's nothing to return, so 204 No Content is the right choice.
A Few Things That Signal a Mature API
Beyond the core patterns, a few details separate a thoughtfully designed API from a rough one:
Envelope your responses consistently. Wrap data in a data key so you have room to add metadata later without breaking the response structure:
{ "data": { "id": 1, "name": "Jane" } } // single resource
{ "data": [...], "pagination": { ... } } // collectionUse ISO 8601 for all timestamps. "2024-03-15T10:30:00Z" is unambiguous and parseable by every language. Unix timestamps and locale-specific date strings are both footguns.
Include a requestId in every response. Generate a unique ID per request, include it in the response headers and error bodies, and log it on every request. When a user reports an issue, you can find the exact request in your logs instantly.
Document with examples, not just schema. Schema tells developers what fields exist. Examples show them what a real request and response look like. Both are necessary.
Summary
Good API design is mostly about consistency and predictability. Use resources and HTTP methods correctly. Return meaningful status codes. Define a standard error format and never deviate from it. Paginate every collection. Version from the start. The patterns in this guide aren't arbitrary conventions — each one exists because ignoring it creates a real problem that someone, somewhere, has already paid for.
An API that's a pleasure to use gets adopted. An API that's confusing gets wrapped in abstraction layers, worked around, and eventually replaced. The investment in getting these fundamentals right is one of the best returns in backend development.
Comments (0)
Login to post a comment.