Audience
This guide is for developers who want to design REST APIs that are intuitive, consistent, and maintainable:
- Backend developers building APIs from scratch and wanting to follow best practices
- Tech leads establishing API design standards for their teams
- Frontend developers who want to understand what makes an API well-designed
- Anyone who has struggled with inconsistent APIs and wants to do better
You should already understand HTTP methods and status codes. If not, start with HTTP for REST APIs.
Goal
After reading this guide, you’ll be able to:
- Design URLs that follow REST conventions and are intuitive to use
- Model resources and their relationships properly
- Implement pagination, filtering, and sorting that scales
- Create consistent error responses that help developers debug issues
- Apply basic versioning to future-proof your API
- Make informed design decisions backed by clear reasoning
This guide builds practical skillsβyou’ll be able to design real APIs after reading it.
1. The Foundation: Resource-Oriented Thinking
Before writing any code, you need to shift your mindset from “endpoints that do things” to “resources that exist.”
Think Nouns, Not Verbs
The most common API design mistake is creating action-oriented endpoints:
# BAD: Action-oriented (RPC-style)
POST /createUser
POST /getUserById
POST /updateUserEmail
POST /deleteUser
POST /sendPasswordReset
These endpoints are verbs. They describe what you do, not what exists. This approach leads to inconsistent, unpredictable APIs.
Instead, think about resourcesβthe “things” in your system:
# GOOD: Resource-oriented (REST-style)
POST /users # Create a user
GET /users/123 # Get user 123
PATCH /users/123 # Update user 123
DELETE /users/123 # Delete user 123
POST /users/123/password-reset # Trigger password reset
HTTP methods already convey the action. Your URLs should identify what is being acted upon.
Identifying Resources
Resources are typically:
- Business entities: Users, products, orders, invoices
- Relationships: Memberships, friendships, subscriptions
- Actions (when necessary): Password resets, email verifications
Ask yourself: “What are the things in my system that have identity and can be manipulated?”
graph TD
A[Your Domain] --> B[Business Entities]
A --> C[Relationships]
A --> D[Actions as Resources]
B --> B1["users"]
B --> B2["products"]
B --> B3["orders"]
C --> C1["memberships"]
C --> C2["subscriptions"]
D --> D1["password-resets"]
D --> D2["email-verifications"]
style A fill:#e3f2fd
style B fill:#fff3e0
style C fill:#fff3e0
style D fill:#fff3e02. URL Design: The Art of Naming
URLs are the interface of your API. They should be predictable, readable, and consistent.
Use Plural Nouns
Collections should use plural nouns:
# GOOD: Plural nouns
GET /users
GET /products
GET /orders
# BAD: Singular nouns (inconsistent)
GET /user
GET /product
GET /order
Even for singleton resources, consider the collection pattern:
# Singleton in a collection context
GET /users/me # Current user
GET /settings/current # Current settings
Hierarchy and Nesting
Resources often have relationships. Express these through URL hierarchy:
# User's orders
GET /users/123/orders
# Specific order from a user
GET /users/123/orders/456
# Products in an order
GET /users/123/orders/456/products
But don’t nest too deep. More than 2-3 levels becomes unwieldy:
# TOO DEEP: Hard to read and use
GET /companies/1/departments/2/teams/3/members/4/tasks/5
# BETTER: Flatten when possible
GET /tasks/5
GET /tasks?team_id=3
Rule of thumb: If a resource has a global identifier, it can be accessed directly.
Naming Conventions
| Convention | Example | Use When |
|---|---|---|
| Lowercase | /users, /products | Always |
| Hyphens for multi-word | /order-items, /password-resets | Always |
| Plural for collections | /users, /orders | For collections |
| No trailing slashes | /users not /users/ | Always |
| No file extensions | /users not /users.json | Always |
| No verbs in URLs | /users not /getUsers | Always (use HTTP methods) |
URL Design Decision Tree
flowchart TD
A[Design a new endpoint] --> B{Is it a collection
of things?}
B -->|Yes| C[Use plural noun:
/resources]
B -->|No| D{Is it a specific
item?}
D -->|Yes| E[Use collection + ID:
/resources/123]
D -->|No| F{Is it nested
under another resource?}
F -->|Yes| G{Depth > 2 levels?}
F -->|No| H[Create as top-level:
/resources]
G -->|Yes| I[Flatten it:
/child-resources?parent_id=X]
G -->|No| J[Nest it:
/parents/X/children]
style C fill:#c8e6c9
style E fill:#c8e6c9
style H fill:#c8e6c9
style I fill:#c8e6c9
style J fill:#c8e6c93. Collections and Items: Two Patterns
Every resource type typically exposes two patterns: the collection and the item.
Collection Operations
Collections represent groups of resources:
# List all users (collection)
GET /users
β Returns: Array of users
# Create a new user (add to collection)
POST /users
β Body: New user data
β Returns: Created user with ID
# Bulk operations (less common)
DELETE /users?status=inactive
β Deletes multiple users matching criteria
Item Operations
Items represent individual resources:
# Get a specific user (item)
GET /users/123
β Returns: Single user object
# Update a specific user (partial)
PATCH /users/123
β Body: Fields to update
β Returns: Updated user
# Replace a user (full)
PUT /users/123
β Body: Complete user object
β Returns: Replaced user
# Delete a specific user
DELETE /users/123
β Returns: 204 No Content
The Complete CRUD Pattern
| Operation | HTTP Method | URL | Request Body | Response |
|---|---|---|---|---|
| List all | GET | /users | - | Array of users |
| Get one | GET | /users/123 | - | Single user |
| Create | POST | /users | New user | Created user + Location header |
| Update partial | PATCH | /users/123 | Changed fields | Updated user |
| Replace full | PUT | /users/123 | Complete user | Replaced user |
| Delete | DELETE | /users/123 | - | 204 No Content |
4. Pagination: Handling Large Collections
Real-world collections can have millions of items. You need pagination.
Three Pagination Strategies
graph LR
A[Pagination Strategies] --> B[Offset-based]
A --> C[Cursor-based]
A --> D[Keyset-based]
B --> B1["Simple, random access"]
B --> B2["Slow on large datasets"]
C --> C1["Fast, stable"]
C --> C2["No random access"]
D --> D1["Fast, deterministic"]
D --> D2["Requires sorted field"]
style B fill:#fff9c4
style C fill:#c8e6c9
style D fill:#c8e6c9Offset-Based Pagination
The simplest approach. Specify offset (skip) and limit (take):
GET /users?offset=0&limit=20 # Page 1
GET /users?offset=20&limit=20 # Page 2
GET /users?offset=40&limit=20 # Page 3
Response with pagination metadata:
{
"data": [
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"}
],
"pagination": {
"offset": 0,
"limit": 20,
"total": 1547
}
}
Pros:
- Simple to implement
- Supports random access (jump to page 50)
- Easy to understand
Cons:
- Slow on large datasets (database must count offset rows)
- Inconsistent results when data changes between requests
- Performance degrades:
OFFSET 1000000is expensive
Use when: Small datasets (< 10,000 items), random access needed.
Cursor-Based Pagination
Uses an opaque cursor (encoded position marker):
GET /users?limit=20
β Returns: data + cursor
GET /users?limit=20&cursor=eyJpZCI6MjB9
β Returns: next page + new cursor
Response:
{
"data": [
{"id": 21, "name": "Carol"},
{"id": 22, "name": "Dave"}
],
"pagination": {
"cursor": "eyJpZCI6NDB9",
"has_more": true
}
}
Pros:
- Fast on large datasets (no offset counting)
- Stable results (won’t skip/duplicate on data changes)
- Works well with real-time data
Cons:
- No random access (must traverse sequentially)
- Cursor can become invalid if referenced item is deleted
- More complex to implement
Use when: Large datasets, infinite scroll UIs, real-time feeds.
Keyset-Based Pagination
Uses the last seen value of a sorted field:
GET /users?limit=20&sort=created_at
β Returns: users sorted by created_at
GET /users?limit=20&sort=created_at&after=2026-01-01T12:00:00Z
β Returns: users created after that timestamp
Response:
{
"data": [
{"id": 45, "name": "Eve", "created_at": "2026-01-01T12:00:01Z"},
{"id": 46, "name": "Frank", "created_at": "2026-01-01T12:00:02Z"}
],
"pagination": {
"after": "2026-01-01T12:00:20Z",
"has_more": true
}
}
Pros:
- Very fast (uses database indexes directly)
- Deterministic results
- No counting required
Cons:
- Requires a unique, sortable field
- No random access
- Complex for multi-column sorts
Use when: Large datasets sorted by timestamp or ID.
Pagination Response Format
Always include pagination metadata:
{
"data": [...],
"pagination": {
"total": 1547,
"limit": 20,
"offset": 40,
"has_more": true,
"links": {
"self": "/users?offset=40&limit=20",
"first": "/users?offset=0&limit=20",
"prev": "/users?offset=20&limit=20",
"next": "/users?offset=60&limit=20",
"last": "/users?offset=1540&limit=20"
}
}
}
5. Filtering: Finding What You Need
Collections need filtering to be useful.
Query Parameter Syntax
The most common approach uses query parameters:
# Simple equality filters
GET /users?status=active
GET /users?role=admin
GET /users?status=active&role=admin # AND logic
# Multiple values (OR logic)
GET /users?status=active,inactive
GET /users?role=admin&role=editor # Multiple params
# Nested field filtering
GET /users?address.city=London
Operators for Complex Filters
For comparisons beyond equality, use operator suffixes or special syntax:
# Approach 1: Operator suffixes
GET /products?price_gte=100 # price >= 100
GET /products?price_lte=500 # price <= 500
GET /products?created_at_gt=2026-01-01 # created after date
# Approach 2: Bracket notation
GET /products?price[gte]=100
GET /products?price[lte]=500
GET /products?created_at[gt]=2026-01-01
# Approach 3: Special operators
GET /products?filter=price:gte:100
GET /products?filter=price:between:100,500
Common Filter Operators
| Operator | Meaning | Example |
|---|---|---|
eq (default) | Equals | ?status=active |
ne | Not equals | ?status_ne=deleted |
gt | Greater than | ?price_gt=100 |
gte | Greater than or equal | ?price_gte=100 |
lt | Less than | ?price_lt=500 |
lte | Less than or equal | ?price_lte=500 |
in | In list | ?status_in=active,pending |
contains | String contains | ?name_contains=john |
starts | String starts with | ?name_starts=john |
Search vs. Filter
Distinguish between structured filtering and full-text search:
# Structured filter (exact field matching)
GET /products?category=electronics&brand=Sony
# Full-text search (searches across multiple fields)
GET /products?q=wireless+headphones
GET /products?search=wireless+headphones
6. Sorting: Ordering Results
Sorting controls the order of collection results.
Single-Field Sorting
# Sort by single field
GET /users?sort=name # Ascending (default)
GET /users?sort=name:asc # Explicit ascending
GET /users?sort=name:desc # Descending
GET /users?sort=-name # Prefix notation: - means desc
Multi-Field Sorting
# Multiple sort fields (comma-separated)
GET /users?sort=role:asc,name:asc
GET /users?sort=role,-created_at # role asc, then created_at desc
Sort Response Headers
Include sort info in the response:
{
"data": [...],
"sorting": {
"fields": [
{"field": "role", "direction": "asc"},
{"field": "created_at", "direction": "desc"}
]
}
}
Default Sorting
Always define a default sort order for consistency:
# If no sort specified
GET /users
β Defaults to: sort=created_at:desc (newest first)
# Or: sort=id:asc (stable order)
7. Error Responses: Helping Developers Debug
Good error responses make your API easier to integrate with.
Consistent Error Structure
Every error response should follow the same structure:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The request contains invalid fields",
"details": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Email must be a valid email address"
},
{
"field": "age",
"code": "OUT_OF_RANGE",
"message": "Age must be between 18 and 120"
}
],
"request_id": "req_abc123xyz"
}
}
Error Response Fields
| Field | Purpose | Example |
|---|---|---|
code | Machine-readable error type | "VALIDATION_ERROR", "NOT_FOUND" |
message | Human-readable explanation | "User not found" |
details | Field-level errors (for validation) | Array of field errors |
request_id | For debugging/support | "req_abc123xyz" |
documentation_url | Link to help docs (optional) | "https://api.example.com/docs/errors/RATE_LIMITED" |
Error Code Categories
Define a consistent set of error codes:
# Authentication/Authorization
AUTHENTICATION_REQUIRED β 401
INVALID_CREDENTIALS β 401
TOKEN_EXPIRED β 401
PERMISSION_DENIED β 403
# Resource errors
NOT_FOUND β 404
ALREADY_EXISTS β 409
CONFLICT β 409
# Validation errors
VALIDATION_ERROR β 400
INVALID_FORMAT β 400
MISSING_FIELD β 400
OUT_OF_RANGE β 400
# Rate limiting
RATE_LIMITED β 429
# Server errors
INTERNAL_ERROR β 500
SERVICE_UNAVAILABLE β 503
Error Decision Tree
flowchart TD
A[Error occurred] --> B{Authentication
issue?}
B -->|Yes| C{Token present?}
B -->|No| D{Resource
exists?}
C -->|No| E["401 + AUTHENTICATION_REQUIRED"]
C -->|Yes| F{Token valid?}
F -->|No| G["401 + INVALID_CREDENTIALS"]
F -->|Yes| H["403 + PERMISSION_DENIED"]
D -->|No| I["404 + NOT_FOUND"]
D -->|Yes| J{Request
valid?}
J -->|No| K["400/422 + VALIDATION_ERROR"]
J -->|Yes| L{Business rule
violation?}
L -->|Yes| M["409 + CONFLICT or custom"]
L -->|No| N["500 + INTERNAL_ERROR"]
style E fill:#ffccbc
style G fill:#ffccbc
style H fill:#ffccbc
style I fill:#ffccbc
style K fill:#fff9c4
style M fill:#fff9c4
style N fill:#ffcdd2Validation Error Example
For form/input validation, provide detailed field-level errors:
POST /users
Content-Type: application/json
{
"name": "",
"email": "not-an-email",
"age": -5
}
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The request body contains invalid fields",
"details": [
{
"field": "name",
"code": "REQUIRED",
"message": "Name is required"
},
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Email must be a valid email address"
},
{
"field": "age",
"code": "OUT_OF_RANGE",
"message": "Age must be a positive number"
}
]
}
}
8. Field Selection: Partial Responses
Let clients request only the fields they need.
Sparse Fieldsets
# Return only specific fields
GET /users/123?fields=id,name,email
# Response
{
"id": 123,
"name": "Alice",
"email": "[email protected]"
}
# Compare to full response
GET /users/123
{
"id": 123,
"name": "Alice",
"email": "[email protected]",
"phone": "+1-555-1234",
"address": {...},
"preferences": {...},
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-10T12:00:00Z"
}
Nested Field Selection
For related resources:
# Select fields on nested objects
GET /users/123?fields=id,name,address.city,address.country
# Response
{
"id": 123,
"name": "Alice",
"address": {
"city": "London",
"country": "UK"
}
}
Benefits of Field Selection
- Reduced bandwidth: Send only what’s needed
- Faster responses: Less data to serialize
- Mobile-friendly: Critical for limited bandwidth
- Privacy: Don’t expose fields client doesn’t need
9. API Versioning: Planning for Change
APIs evolve. Versioning helps you make changes without breaking existing clients.
Why Version?
Without versioning:
graph LR
A[API Change] --> B[All Clients Break]
B --> C[Angry Users]
B --> D[Support Tickets]
B --> E[Lost Trust]
style A fill:#ffccbc
style B fill:#ffcdd2With versioning:
graph LR
A[API v2 Released] --> B[v1 Still Works]
A --> C[Clients Migrate
at Their Pace]
B --> D[No Breaking Changes]
C --> D
D --> E[Happy Users]
style A fill:#c8e6c9
style D fill:#c8e6c9
style E fill:#c8e6c9URL Path Versioning (Recommended)
The simplest and most visible approach:
# Version in URL path
GET /v1/users
GET /v2/users
# Full URL
https://api.example.com/v1/users
https://api.example.com/v2/users
Pros:
- Highly visible and explicit
- Easy to test and debug
- Works with all HTTP tools
- Clear in documentation
Cons:
- Changes all URLs when version bumps
- Can lead to code duplication
This is the default choice for most APIs. Start here.
Header Versioning (Alternative)
Version specified in request header:
GET /users
Accept: application/vnd.example.v1+json
GET /users
Accept: application/vnd.example.v2+json
Or custom header:
GET /users
X-API-Version: 1
GET /users
X-API-Version: 2
Pros:
- Clean URLs
- Same URL for all versions
- Follows HTTP content negotiation
Cons:
- Less visible (not in URL)
- Harder to test in browser
- Easy to forget the header
Query Parameter Versioning (Not Recommended)
GET /users?version=1
GET /users?version=2
Avoid this approach. It mixes versioning with query semantics and can cause caching issues.
When to Create a New Version
Create a new version for breaking changes:
- Removing a field
- Renaming a field
- Changing a field’s type
- Changing required/optional status
- Changing error response format
- Changing authentication scheme
Non-breaking changes don’t require a new version:
- Adding new fields (additive)
- Adding new endpoints
- Adding new optional parameters
- Adding new error codes
Version Lifecycle
v1 (current) β v2 (released) β v1 (deprecated) β v1 (sunset)
β
6-12 months warning
β
v1 removed
Always announce deprecation in advance (6-12 months minimum for production APIs).
10. Putting It All Together: E-Commerce API Example
Let’s design a complete e-commerce API following all the principles.
Resource Hierarchy
graph TD
A[API Root] --> B[/users]
A --> C[/products]
A --> D[/orders]
A --> E[/categories]
B --> B1[/users/:id]
B1 --> B2[/users/:id/orders]
B1 --> B3[/users/:id/addresses]
C --> C1[/products/:id]
C1 --> C2[/products/:id/reviews]
D --> D1[/orders/:id]
D1 --> D2[/orders/:id/items]
E --> E1[/categories/:id]
E1 --> E2[/categories/:id/products]
style A fill:#e3f2fdComplete Endpoint Design
Users:
GET /v1/users # List users (admin)
POST /v1/users # Create user (signup)
GET /v1/users/me # Get current user
PATCH /v1/users/me # Update current user
GET /v1/users/:id # Get user by ID (admin)
GET /v1/users/:id/orders # Get user's orders
GET /v1/users/:id/addresses # Get user's addresses
POST /v1/users/:id/addresses # Add address
Products:
GET /v1/products # List products
GET /v1/products/:id # Get product
POST /v1/products # Create product (admin)
PATCH /v1/products/:id # Update product (admin)
DELETE /v1/products/:id # Delete product (admin)
GET /v1/products/:id/reviews # Get product reviews
POST /v1/products/:id/reviews # Add review
Orders:
GET /v1/orders # List orders (user's own or admin)
POST /v1/orders # Create order
GET /v1/orders/:id # Get order
PATCH /v1/orders/:id # Update order (status changes)
DELETE /v1/orders/:id # Cancel order
GET /v1/orders/:id/items # Get order items
Categories:
GET /v1/categories # List categories
GET /v1/categories/:id # Get category
GET /v1/categories/:id/products # Get products in category
Example: List Products with Filters
GET /v1/products?category=electronics&price_gte=100&price_lte=500&sort=-rating&limit=20&offset=0
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": [
{
"id": "prod_123",
"name": "Wireless Headphones",
"category": "electronics",
"price": 299.99,
"rating": 4.8,
"in_stock": true
},
{
"id": "prod_456",
"name": "Bluetooth Speaker",
"category": "electronics",
"price": 149.99,
"rating": 4.6,
"in_stock": true
}
],
"pagination": {
"offset": 0,
"limit": 20,
"total": 156,
"has_more": true
},
"filters_applied": {
"category": "electronics",
"price_gte": 100,
"price_lte": 500
},
"sorting": {
"field": "rating",
"direction": "desc"
}
}
Example: Create Order with Error
POST /v1/orders
Content-Type: application/json
Authorization: Bearer eyJhbG...
{
"items": [
{"product_id": "prod_123", "quantity": 0},
{"product_id": "prod_999", "quantity": 2}
],
"shipping_address_id": "addr_456"
}
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Order could not be created due to validation errors",
"details": [
{
"field": "items[0].quantity",
"code": "OUT_OF_RANGE",
"message": "Quantity must be at least 1"
},
{
"field": "items[1].product_id",
"code": "NOT_FOUND",
"message": "Product 'prod_999' does not exist"
}
],
"request_id": "req_xyz789"
}
}
What’s Next
This guide covered the fundamentals of REST API designβenough to build a well-structured API.
For deeper topics like:
- API evolution without breaking clients
- Versioning strategies for live APIs
- Migration patterns for existing APIs
- Long-term backward compatibility
- Deprecation and sunset policies
See the upcoming course: REST API Design That Doesn’t Break.
Related Vocabulary Terms
Deepen your understanding:
- Resource - The fundamental building block of REST APIs
- Pagination - Strategies for handling large collections
- Filtering - Narrowing down collection results
- Sorting - Ordering collection results
- Error Handling - Communicating failures to clients
- API Versioning - Evolving APIs without breaking clients