A well-designed API is a joy to work with. A poorly designed one generates support tickets, slows down integration, and creates technical debt that compounds over years. The difference between the two is usually not technical complexity but consistency and predictability.
This guide covers every aspect of REST API design that matters in practice. Whether you are building an API for internal use, a public developer platform, or a microservices architecture, these principles apply. Every recommendation includes concrete examples and the reasoning behind it.
1. URL Structure and Naming Conventions
URLs are the most visible part of your API. They should be predictable enough that a developer can guess endpoints after seeing a few examples. Consistency here pays for itself in reduced documentation needs and fewer support questions.
Use Nouns, Not Verbs
Resources are nouns. The HTTP method provides the verb. Do not repeat the action in the URL:
Do
GET /users
POST /users
GET /users/42
PUT /users/42
DELETE /users/42
GET /users/42/orders
Don't
GET /getUsers
POST /createUser
GET /getUserById/42
POST /updateUser/42
POST /deleteUser/42
GET /getOrdersForUser/42
Pluralize Resource Names
Use plural nouns consistently. Even when retrieving a single resource, the collection is plural and the individual item is identified by its ID:
# Collection
GET /api/v1/articles
# Individual item
GET /api/v1/articles/42
# Nested resource (sub-collection)
GET /api/v1/articles/42/comments
# Specific nested item
GET /api/v1/articles/42/comments/7
Use Kebab-Case
URLs are case-insensitive by convention. Use kebab-case (hyphens) for multi-word resource names:
Do
/api/v1/blog-posts
/api/v1/user-profiles
/api/v1/order-items
Don't
/api/v1/blogPosts
/api/v1/user_profiles
/api/v1/OrderItems
Keep Nesting Shallow
Deeply nested URLs become unmanageable. Limit nesting to one level. If you need deeper relationships, use query parameters or flatten the URL:
Do (shallow)
GET /comments?article_id=42
GET /articles/42/comments
GET /orders?user_id=7
Don't (deeply nested)
GET /users/7/orders/12/items/3/reviews
GET /orgs/5/teams/2/members/9/tasks
Actions on Resources
Some operations do not map cleanly to CRUD. For actions like "archive", "publish", or "send", there are two accepted patterns:
# Option A: Sub-resource representing the action
POST /api/v1/articles/42/publish
POST /api/v1/orders/99/cancel
POST /api/v1/users/7/verify-email
# Option B: Treat the state as a field (PATCH the resource)
PATCH /api/v1/articles/42
{
"status": "published"
}
# Option A is clearer for complex actions with side effects
# Option B is better for simple state transitions
2. HTTP Methods: Proper Usage
HTTP methods have well-defined semantics. Using them correctly makes your API predictable and enables caching, retry logic, and tooling that rely on these semantics.
| Method | Purpose | Idempotent | Safe | Request Body |
|---|---|---|---|---|
| GET | Retrieve a resource or collection | Yes | Yes | No |
| POST | Create a new resource | No | No | Yes |
| PUT | Replace a resource entirely | Yes | No | Yes |
| PATCH | Partially update a resource | No* | No | Yes |
| DELETE | Remove a resource | Yes | No | Optional |
Idempotent means making the same request multiple times produces the same result. GET, PUT, and DELETE are idempotent. POST is not (calling POST /users twice creates two users). PATCH can be idempotent if you send the target state ("set name to X") rather than a delta ("append Y to name").
Safe means the request does not modify server state. Only GET (and HEAD/OPTIONS) are safe. This distinction matters because proxies and browsers feel free to retry safe requests.
PUT vs PATCH
This distinction confuses many developers. The rule is simple:
- PUT replaces the entire resource. If you omit a field, it gets cleared. You send the complete new state.
- PATCH updates only the fields you include. Omitted fields remain unchanged.
# Current resource state:
# { "id": 42, "name": "Alice", "email": "alice@ex.com", "role": "admin" }
# PUT — replaces the entire resource
PUT /api/v1/users/42
{
"name": "Alice Smith",
"email": "alice@ex.com"
}
# Result: { "id": 42, "name": "Alice Smith", "email": "alice@ex.com" }
# "role" field is GONE because it was not included
# PATCH — updates only included fields
PATCH /api/v1/users/42
{
"name": "Alice Smith"
}
# Result: { "id": 42, "name": "Alice Smith", "email": "alice@ex.com", "role": "admin" }
# Only "name" changed, everything else preserved
Most APIs should use PATCH for updates. PUT is technically more correct for full replacements but is rarely what clients actually want. Accidentally clearing a field because the client did not send it is a common source of bugs with PUT.
3. Status Codes Guide
HTTP status codes communicate what happened with the request. Using the right code enables clients to handle responses programmatically without parsing error messages. Here are the codes every API should use:
Success Codes (2xx)
| Code | Meaning | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH, or DELETE with a response body |
| 201 | Created | Successful POST that created a new resource. Include a Location header pointing to the new resource. |
| 204 | No Content | Successful DELETE or action with no response body |
Client Error Codes (4xx)
| Code | Meaning | When to Use |
|---|---|---|
| 400 | Bad Request | Malformed JSON, missing required fields, validation errors |
| 401 | Unauthorized | Missing or invalid authentication credentials |
| 403 | Forbidden | Authenticated but not authorized for this resource |
| 404 | Not Found | Resource does not exist at the given URL |
| 409 | Conflict | Creating a resource that already exists, or concurrent modification conflict |
| 422 | Unprocessable Entity | Valid JSON, but the data fails business logic validation |
| 429 | Too Many Requests | Rate limit exceeded. Include Retry-After header. |
Server Error Codes (5xx)
| Code | Meaning | When to Use |
|---|---|---|
| 500 | Internal Server Error | Unexpected server failure. Never expose stack traces in production. |
| 502 | Bad Gateway | Upstream service failed (when your API proxies to another service) |
| 503 | Service Unavailable | Server is temporarily down (maintenance, overloaded). Include Retry-After. |
401 Unauthorized means "I do not know who you are." The request needs authentication. 403 Forbidden means "I know who you are, but you are not allowed." Reauthenticating will not help.
4. Pagination, Filtering, and Sorting
Any endpoint that returns a collection must support pagination. Without it, a growing database will eventually make the endpoint unusable.
Offset-Based Pagination
The simplest approach. Good for most use cases. Allow clients to specify page
and per_page (or limit and offset):
# Request
GET /api/v1/articles?page=3&per_page=20&sort=-created_at
# Response
{
"data": [
{ "id": 41, "title": "Article 41", ... },
{ "id": 42, "title": "Article 42", ... },
...
],
"meta": {
"page": 3,
"per_page": 20,
"total": 156,
"total_pages": 8
},
"links": {
"self": "/api/v1/articles?page=3&per_page=20",
"first": "/api/v1/articles?page=1&per_page=20",
"prev": "/api/v1/articles?page=2&per_page=20",
"next": "/api/v1/articles?page=4&per_page=20",
"last": "/api/v1/articles?page=8&per_page=20"
}
}
Cursor-Based Pagination
For large datasets or real-time data, cursor-based pagination is more reliable. It avoids the "page drift" problem where items shift between pages as new items are inserted:
# Request — first page
GET /api/v1/events?limit=50
# Response
{
"data": [ ... ],
"meta": {
"has_more": true,
"next_cursor": "eyJpZCI6MTAwfQ=="
}
}
# Request — next page (use the cursor from the previous response)
GET /api/v1/events?limit=50&cursor=eyJpZCI6MTAwfQ==
Filtering
Use query parameters for filtering. Keep the syntax simple and consistent:
# Exact match
GET /api/v1/users?role=admin
# Multiple values (OR)
GET /api/v1/users?role=admin,editor
# Range (for dates and numbers)
GET /api/v1/orders?created_after=2026-01-01&created_before=2026-02-01
GET /api/v1/products?price_min=10&price_max=50
# Search (text)
GET /api/v1/articles?search=javascript+performance
# Combined filters
GET /api/v1/orders?status=shipped&customer_id=42&sort=-total&per_page=10
Sorting
Use a sort parameter. Prefix with - for descending order:
# Sort by created_at descending (newest first)
GET /api/v1/articles?sort=-created_at
# Sort by multiple fields: first by status, then by name
GET /api/v1/users?sort=status,-name
# Default sort should always be defined and documented
5. Authentication Strategies
Choose the right authentication strategy based on who will consume your API and what security level you need.
API Keys
Simple, suitable for server-to-server communication. Send in a header, never in the URL:
# API Key in header (recommended)
GET /api/v1/data
X-API-Key: sk_live_abc123def456
# Or using Authorization header with custom scheme
GET /api/v1/data
Authorization: ApiKey sk_live_abc123def456
# NEVER in the URL — URLs are logged and cached
# GET /api/v1/data?api_key=sk_live_abc123def456 ← DON'T
JWT (JSON Web Tokens)
Best for user-facing APIs where clients authenticate with credentials and receive a token. Stateless and scalable:
# Step 1: Authenticate and get tokens
POST /api/v1/auth/login
{
"email": "user@example.com",
"password": "correct-horse-battery-staple"
}
# Response
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"refresh_token": "dGhpcyBpcyBhIHJlZnJl...",
"token_type": "Bearer",
"expires_in": 3600
}
# Step 2: Use the access token
GET /api/v1/users/me
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
# Step 3: Refresh when expired
POST /api/v1/auth/refresh
{
"refresh_token": "dGhpcyBpcyBhIHJlZnJl..."
}
OAuth 2.0
For third-party integrations where users grant your API access to another service. Use standard flows like Authorization Code with PKCE for SPAs and mobile apps:
# Step 1: Redirect to authorization endpoint
GET https://auth.example.com/authorize?
response_type=code&
client_id=your_client_id&
redirect_uri=https://yourapp.com/callback&
scope=read+write&
state=random_csrf_token&
code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
code_challenge_method=S256
# Step 2: Exchange code for token
POST https://auth.example.com/token
{
"grant_type": "authorization_code",
"code": "the_auth_code",
"redirect_uri": "https://yourapp.com/callback",
"client_id": "your_client_id",
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
}
Authentication Decision Guide
- Server-to-server only: API Keys (simple, effective).
- User-facing SPA or mobile: JWT with short-lived access tokens and refresh tokens.
- Third-party integrations: OAuth 2.0 with PKCE.
- All of the above: Always use HTTPS. No exceptions.
6. Rate Limiting
Rate limiting protects your API from abuse and ensures fair usage. Every public API needs it. Internal APIs benefit from it too, as a safeguard against runaway scripts and cascading failures.
Rate Limit Headers
Always include rate limit information in response headers so clients can self-regulate:
X-RateLimit-Limit: 1000 # Max requests per window
X-RateLimit-Remaining: 847 # Requests remaining in current window
X-RateLimit-Reset: 1738972800 # Unix timestamp when window resets
# When rate limit is exceeded:
HTTP/1.1 429 Too Many Requests
Retry-After: 30 # Seconds until client can retry
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1738972800
Common Rate Limiting Strategies
- Fixed window: 1000 requests per hour. Simple but allows burst traffic at window boundaries.
- Sliding window: Smooths out the boundary issue by tracking requests in a rolling window.
- Token bucket: Allows short bursts while maintaining an average rate. Most flexible approach.
- Per-endpoint limits: More granular; expensive operations (search, exports) get lower limits than cheap reads.
// Token bucket rate limiter with Redis
import { RateLimiterRedis } from 'rate-limiter-flexible';
import Redis from 'ioredis';
const redis = new Redis('redis://localhost:6379');
const limiter = new RateLimiterRedis({
storeClient: redis,
keyPrefix: 'rl',
points: 100, // 100 requests
duration: 60, // per 60 seconds
blockDuration: 60, // block for 60s when exceeded
});
async function rateLimitMiddleware(req, res, next) {
try {
const key = req.headers['x-api-key'] || req.ip;
const result = await limiter.consume(key);
res.set({
'X-RateLimit-Limit': 100,
'X-RateLimit-Remaining': result.remainingPoints,
'X-RateLimit-Reset': new Date(Date.now() + result.msBeforeNext)
});
next();
} catch (err) {
res.status(429).json({
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: 'Too many requests. Please retry later.',
retry_after: Math.ceil(err.msBeforeNext / 1000)
}
});
}
}
7. Versioning Strategies
APIs evolve. Versioning lets you make breaking changes without disrupting existing clients. There are three common approaches, each with tradeoffs.
URL Path Versioning (Recommended)
The most common and most practical approach. The version is part of the URL path:
GET /api/v1/users/42
GET /api/v2/users/42
# Advantages:
# - Extremely clear which version a request uses
# - Easy to route at the load balancer level
# - Works with every HTTP client, including curl and browsers
# - Easy to deprecate (redirect v1 → v2 with 301)
Header Versioning
# Custom header
GET /api/users/42
X-API-Version: 2
# Or via Accept header (content negotiation)
GET /api/users/42
Accept: application/vnd.myapi.v2+json
# Advantage: cleaner URLs
# Disadvantage: harder to discover, test, and share
Query Parameter Versioning
GET /api/users/42?version=2
# Simple but easy to forget. Can cause caching issues.
# Generally not recommended for new APIs.
Version Lifecycle
Document a clear deprecation policy:
- Announce deprecation at least 6 months before sunset. Use a
Sunsetheader and aDeprecationheader on responses from deprecated versions. - Provide migration guides for every breaking change between versions.
- Monitor usage of deprecated versions. Do not sunset until traffic is negligible.
- Minimize breaking changes. Adding fields is not breaking. Removing or renaming fields is.
# Signal deprecation on v1 responses
Deprecation: true
Sunset: Sat, 01 Aug 2026 00:00:00 GMT
Link: <https://api.example.com/docs/migration-v1-to-v2>; rel="deprecation"
8. Error Response Format
Error responses should be as structured and predictable as success responses. A well-designed error format lets clients handle errors programmatically and gives developers useful debugging information.
Standard Error Format
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The request body contains invalid data.",
"details": [
{
"field": "email",
"message": "Must be a valid email address.",
"code": "INVALID_FORMAT"
},
{
"field": "age",
"message": "Must be between 13 and 150.",
"code": "OUT_OF_RANGE"
}
],
"request_id": "req_abc123xyz",
"documentation_url": "https://api.example.com/docs/errors#VALIDATION_ERROR"
}
}
Error Format Principles
- Machine-readable code: Use a constant string like
VALIDATION_ERRORorNOT_FOUNDthat clients can switch on. Do not rely on HTTP status codes alone, as 400 could mean many things. - Human-readable message: A clear English sentence describing the error. This is for developers reading logs, not for end users.
- Field-level details: For validation errors, specify which fields failed and why. This lets clients highlight specific form fields.
- Request ID: Include a unique request ID that appears in both the response and your server logs. This is essential for debugging.
- Documentation link: Point developers to the relevant error documentation. This dramatically reduces support burden.
Error Handling Implementation
// Custom error classes
class ApiError extends Error {
constructor(statusCode, code, message, details = []) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.details = details;
}
}
class NotFoundError extends ApiError {
constructor(resource, id) {
super(404, 'NOT_FOUND',
`${resource} with id '${id}' was not found.`);
}
}
class ValidationError extends ApiError {
constructor(details) {
super(422, 'VALIDATION_ERROR',
'The request body contains invalid data.', details);
}
}
// Global error handler middleware
function errorHandler(err, req, res, next) {
const statusCode = err.statusCode || 500;
const code = err.code || 'INTERNAL_ERROR';
const message = statusCode === 500
? 'An unexpected error occurred.'
: err.message;
// Log the full error internally
logger.error({
request_id: req.id,
status: statusCode,
code: code,
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
});
res.status(statusCode).json({
error: {
code,
message,
details: err.details || [],
request_id: req.id,
documentation_url:
`https://api.example.com/docs/errors#${code}`
}
});
}
// Usage in a route
app.get('/api/v1/users/:id', async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) throw new NotFoundError('User', req.params.id);
res.json({ data: user });
});
Never expose stack traces, internal error messages, or database details in production API responses. Log them server-side for debugging but return a generic message to clients. Internal details can reveal your technology stack and potential attack vectors.
9. Documentation with OpenAPI
Good documentation is the difference between an API that developers love and one they dread. The OpenAPI Specification (formerly Swagger) is the industry standard for describing REST APIs in a machine-readable format that also generates human-readable docs.
OpenAPI Specification Example
openapi: "3.1.0"
info:
title: "NexTool API"
description: "API for the NexTool AI Automation Studio"
version: "1.0.0"
contact:
email: "api@nextool.app"
servers:
- url: "https://api.nextool.app/v1"
description: "Production"
- url: "https://staging-api.nextool.app/v1"
description: "Staging"
paths:
/users:
get:
summary: "List all users"
operationId: "listUsers"
tags: ["Users"]
parameters:
- name: "page"
in: "query"
schema:
type: "integer"
default: 1
minimum: 1
- name: "per_page"
in: "query"
schema:
type: "integer"
default: 20
minimum: 1
maximum: 100
responses:
"200":
description: "A paginated list of users"
content:
application/json:
schema:
$ref: "#/components/schemas/UserList"
post:
summary: "Create a new user"
operationId: "createUser"
tags: ["Users"]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateUser"
responses:
"201":
description: "User created"
"422":
description: "Validation error"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
Documentation Best Practices
- Include examples for every request and response. Developers learn from examples more than descriptions.
- Document error codes with explanations and suggested fixes for each one.
- Provide a quickstart guide that gets developers to their first successful request in under 5 minutes.
- Show authentication setup step by step, with code samples in multiple languages.
- Maintain a changelog so developers can see what changed between versions.
- Include rate limit information prominently so developers design their integrations accordingly.
Tools for API Documentation
- Swagger UI / Redoc: Generate interactive docs from your OpenAPI spec. Developers can test endpoints directly in the browser.
- Stoplight Studio: Visual editor for designing and documenting APIs with OpenAPI.
- Postman Collections: Export your API as a Postman collection so developers can import and test immediately.
- API Blueprint / RAML: Alternative spec formats if OpenAPI does not fit your needs.
10. API Design Checklist
Use this checklist when designing or reviewing a REST API. It summarizes every principle covered in this guide:
URL Design
- Resources use plural nouns (
/users, not/user) - URLs use kebab-case (
/blog-posts, not/blogPosts) - Nesting is limited to one level
- No verbs in URLs (HTTP methods provide the action)
- Base URL includes API version (
/api/v1/)
HTTP Methods
- GET for retrieval (never modifies state)
- POST for creation
- PATCH for partial updates (not PUT, unless you truly need full replacement)
- DELETE for removal
- Idempotency is respected for GET, PUT, DELETE
Status Codes
- 200 for successful operations with a body
- 201 for successful creation (with Location header)
- 204 for successful operations with no body
- 400 for malformed requests
- 401 for missing authentication
- 403 for insufficient permissions
- 404 for missing resources
- 422 for validation errors
- 429 for rate limiting (with Retry-After header)
- 500 never exposes internal details
Data Format
- JSON responses use camelCase or snake_case consistently
- Dates use ISO 8601 format (
2026-02-08T12:00:00Z) - Collections are wrapped in a
datakey with pagination metadata - Null values are explicit (not omitted)
Security
- HTTPS only (no exceptions)
- Authentication tokens in headers, never in URLs
- Rate limiting with clear headers
- CORS configured correctly for browser clients
- Input validation on all endpoints
- No sensitive data in error responses
Developer Experience
- Consistent error format with machine-readable codes
- Request IDs for debugging
- Comprehensive documentation with examples
- Deprecation headers with sunset dates
- Pagination on all collection endpoints
- Filtering and sorting support
A great API is not one that does everything. It is one where a developer can predict what the next endpoint looks like after learning the first three.