APIs are contracts. Once a client is built against them — whether that client is your own frontend, a mobile app, or a third-party integration — changing them becomes expensive. Getting your REST API design right from the start saves your team and your users from pain down the road.
This guide covers the practical decisions that determine whether an API is a joy to work with or a constant source of friction.
Use Nouns for Resources, Not Verbs
The most fundamental REST convention is that URLs represent resources (things), not actions (verbs). The HTTP method expresses what you want to do with the resource.
SponsoredCheck out today's featured offer →Wrong:
GET /getUser
POST /createUser
POST /deleteUser
POST /updateUserEmail
Right:
GET /users → list users
POST /users → create a user
GET /users/{id} → get a specific user
PUT /users/{id} → replace a user
PATCH /users/{id} → update part of a user
DELETE /users/{id} → delete a user
Use plural nouns for resource collections (/users, not /user). This is more consistent — GET /users/123 reads naturally as "get user 123 from the users collection."
Use HTTP Methods Correctly
Each HTTP method has a specific semantic meaning. Use them consistently:
| Method | Meaning | Idempotent? | Safe? |
|---|---|---|---|
| GET | Retrieve a resource | Yes | Yes |
| POST | Create a new resource | No | No |
| PUT | Replace an entire resource | Yes | No |
| PATCH | Partially update a resource | Yes | No |
| DELETE | Delete a resource | Yes | No |
Safe means the operation has no side effects. Idempotent means calling it multiple times produces the same result as calling it once. These properties matter for caching, retries, and client error handling.
Structure URLs for Nested Resources
When resources have a natural parent-child relationship, express that in the URL:
GET /organizations/{orgId}/users → list users in an org
POST /organizations/{orgId}/users → add a user to an org
GET /projects/{projectId}/tasks → list tasks in a project
POST /projects/{projectId}/tasks → create a task in a project
GET /projects/{projectId}/tasks/{id} → get a specific task
Avoid nesting more than two levels deep. Deeply nested URLs become hard to read and often indicate a data model problem.
For relationships that don't fit a strict hierarchy, use query parameters or a separate endpoint:
GET /tasks?assignedTo={userId} → filter tasks by assignee
GET /users/{id}/tasks → get all tasks for a user
Use Consistent Status Codes
HTTP status codes communicate what happened to a request. Use them correctly so clients can handle responses predictably.
2xx — Success:
200 OK— General success for GET, PUT, PATCH, DELETE201 Created— Resource was created (POST). Include aLocationheader pointing to the new resource.202 Accepted— Request accepted for async processing (e.g., a background job was queued)204 No Content— Success with no response body (common for DELETE)
4xx — Client Error:
400 Bad Request— Invalid request body or parameters401 Unauthorized— Authentication required or token invalid403 Forbidden— Authenticated but not authorized404 Not Found— Resource doesn't exist409 Conflict— Request conflicts with current state (e.g., duplicate email)422 Unprocessable Entity— Validation errors429 Too Many Requests— Rate limit exceeded
5xx — Server Error:
500 Internal Server Error— Unhandled server error503 Service Unavailable— Server temporarily overloaded or down
Design a Consistent Error Response Format
Inconsistent error formats are one of the most frustrating API design problems. Define a standard error structure and use it everywhere:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{
"field": "email",
"message": "Must be a valid email address"
}
]
}
}
Key elements of a good error response:
- Machine-readable code: A stable string code (not the HTTP status) that clients can programmatically react to
- Human-readable message: A description that makes sense to a developer
- Field-level details: For validation errors, which fields failed and why
Do not put technical stack traces in error responses. Log them server-side, but never expose them to clients.
Version Your API From Day One
APIs evolve. Clients built against your API can't always upgrade immediately. Versioning gives you the freedom to make breaking changes without breaking existing clients.
The most common approach is URL versioning:
/api/v1/users
/api/v2/users
This is explicit and easy to understand. Clients can migrate to the new version on their own schedule.
Alternative: Header versioning (Accept: application/vnd.api.v2+json). This keeps URLs clean but is less visible and harder to test in a browser.
Whichever you choose, decide before launch and document it clearly. Changing your versioning strategy later is painful.
Implement Authentication and Authorization Properly
JWT (JSON Web Tokens) are the most common authentication mechanism for REST APIs. The client includes the token in the Authorization header:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Key practices:
- Set a short expiration on access tokens (15 minutes to 1 hour) and provide refresh tokens for obtaining new access tokens
- Validate the token on every request server-side — never trust the payload without verifying the signature
- Include the user's ID and roles in the token payload to avoid database lookups on every request
For public APIs accessed by third-party developers, use API keys or OAuth 2.0.
Authorization is different from authentication. Authentication is "who are you?" Authorization is "what are you allowed to do?" Always check both:
// Wrong: only checks authentication
if (!user) return 403;
// Right: checks authentication AND authorization
if (!user) return 401;
if (!user.hasPermission('read:reports')) return 403;
Add Pagination to List Endpoints
Never return unbounded lists. As your data grows, an endpoint that returns all records will eventually become too slow or too large.
Implement pagination on every list endpoint. Cursor-based pagination is more robust for large or frequently-changing datasets; offset-based is simpler:
GET /users?page=2&limit=25
Return pagination metadata in the response:
{
"data": [...],
"pagination": {
"page": 2,
"limit": 25,
"total": 143,
"hasNextPage": true
}
}
Rate Limit Your API
Rate limiting protects your infrastructure from both accidental abuse (a client with a bug making millions of requests) and intentional abuse (scraping, credential stuffing).
Return 429 Too Many Requests when a client exceeds their limit. Include headers telling them when they can retry:
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1716825600
Retry-After: 60
Document Everything
An undocumented API is only as useful as the developer who wrote it. Document your API before other developers — including your future self — need to use it.
OpenAPI (Swagger) is the standard format for REST API documentation. It generates interactive documentation that developers can test directly in the browser. Tools like Swagger UI, Redoc, and Scalar can render OpenAPI specs into polished documentation sites.
At minimum, document:
- Every endpoint (path, method, description)
- Request parameters and body schemas
- Response schemas for success and error cases
- Authentication requirements
- Rate limiting policies
Your API design document in your technical specification should capture the core contract. Full API documentation should live in a tool that stays in sync with the code.
A well-designed API communicates intent clearly, handles errors gracefully, and evolves without breaking the clients that depend on it. The time invested in getting these decisions right before writing the first route handler pays dividends for the lifetime of the product.