REST API Design: Practical Best Practices
Field-tested REST API design — resource naming, versioning, pagination, filtering, sorting, idempotency, and consistent error envelopes. Conventions that age well in production.
What you'll learn
- ✓How to name resources and structure URLs
- ✓Versioning strategies — URL, header, and "don't version"
- ✓Pagination patterns — offset, cursor, and link headers
- ✓Filtering, sorting, and search conventions that scale
- ✓How to make POST and PATCH safely retryable with idempotency keys
- ✓How to shape error responses so clients can act on them
Prerequisites
- •A rough idea of REST — see What Is REST?
- •Built or worked with a small CRUD API before
REST is a style, not a standard. Two REST APIs that both “follow the rules” can still feel wildly different to use. This post covers the conventions that make an API feel professional, predictable, and pleasant to integrate — the polish that separates a hobby project from a public API.
None of these are controversial; all of them are followed by APIs like Stripe, GitHub, and Twilio. Borrow freely.
Resource naming
A resource is a thing you can identify. URLs should name those things, not the actions on them.
# Good
GET /users
GET /users/42
POST /users
PUT /users/42
DELETE /users/42
# Bad
GET /getUsers
POST /createUser
POST /user/delete?id=42
Three rules that cover 90% of cases:
- Plural nouns for collections.
/users, not/user. - IDs in the path, not the query string.
/users/42, not/users?id=42. - HTTP methods are the verb. Don’t add verbs to URLs.
Nested resources
When a resource only makes sense in the context of a parent, nest it:
# Comments belong to a post
GET /posts/9/comments # list comments on post 9
POST /posts/9/comments # create a comment on post 9
GET /posts/9/comments/3 # fetch one comment
DELETE /posts/9/comments/3 # delete it
Avoid deeper than two levels (/posts/9/comments/3/replies/1/reactions). Past that, flatten:
# Once a resource has its own identity, it can live at the top level
GET /reactions/abc123
Some teams expose both — a flat URL for direct access plus a nested URL for listing — which is fine.
Action endpoints
Pure REST insists everything is a resource. Pragmatic REST allows the occasional action when the resource model is awkward:
# Hard to model as state changes; fine as a POST verb
POST /orders/42/cancel
POST /messages/9/archive
POST /users/42/password/reset
Two conventions hold up:
- The path includes the resource being acted on.
- It is always POST — actions are neither safe nor idempotent.
Don’t twist yourself into knots inventing a “Cancellation” resource just to keep your URLs noun-pure.
Versioning
APIs change. The three positions:
# 1. URL versioning — most common
GET /v1/users
GET /v2/users
# 2. Header versioning — cleaner URLs, harder to debug
GET /users
Accept: application/vnd.example.v2+json
# 3. No versioning — evolve carefully and never break
GET /users
URL versioning wins on discoverability and debuggability. You can paste a URL into a browser, into a log, into a Slack message, and the version is right there. That matters more than philosophical purity.
Bump a major version only for breaking changes. These are breaking:
- Removing or renaming a field
- Changing a field’s type
- Making an optional field required
- Changing the meaning of an existing field
These are not breaking and don’t require a new version:
- Adding new endpoints
- Adding new optional response fields
- Accepting new optional request fields
Once you ship v2, support v1 for at least 6-12 months. Communicate deprecation dates via headers (Deprecation: true, Sunset: <date>).
Pagination
Never return an unbounded list. Pick one of two strategies up front.
Offset / limit — simple and fine for small datasets
GET /users?limit=20&offset=40
{
"data": [ /* 20 users */ ],
"meta": {
"total": 1278,
"limit": 20,
"offset": 40
}
}
Easy to implement, easy for clients to skip to “page 5.” Breaks down on large datasets — the database must count past 40 rows to find your slice, which gets expensive at offset 100,000.
Cursor-based — scales to millions of rows
GET /users?limit=20&cursor=eyJpZCI6MTAwMH0
{
"data": [ /* 20 users */ ],
"meta": {
"nextCursor": "eyJpZCI6MTAyMH0",
"hasMore": true
}
}
The cursor is an opaque token (typically base64-encoded JSON) pointing to “the row after this one.” The database can use an indexed lookup instead of an offset scan. Cursor pagination doesn’t support jumping to page 5, but real users don’t do that either — they scroll.
Pick cursor pagination unless you are sure the dataset will stay small. Stripe, GitHub, and Twitter all use it.
Filtering, sorting, and search
Use the query string. A convention worth standardising:
# Filters look like equality by default
GET /products?category=shoes&inStock=true
# Comparisons get a suffix
GET /products?price[gte]=10&price[lte]=100
# Sorts use a sort param; prefix with - for descending
GET /products?sort=-createdAt,name
# Free-text search lives on its own param
GET /products?q=running+shoes
A few habits that prevent pain:
- Document allowed filter fields explicitly. Don’t let clients filter by every column — it’s a footgun for indexes.
- Coerce types carefully. Query strings are strings;
?inStock=falseis the string"false", not the boolean. - Bound everything. Cap
limit, restrict free-text searches, time-box expensive filters.
Try it yourself. Design the URL to fetch the second page (20 per page) of unpublished blog posts authored by user 42, sorted by most recent first, that match the search term “graphql.” Using only conventions above, this is straightforward:
GET /posts?author=42&status=draft&q=graphql&sort=-createdAt&limit=20&offset=20If you instead used cursor pagination, the offset=20 would become cursor=<token from previous response>.
Idempotency
A POST that creates a resource is not naturally idempotent — call it twice and you create two resources. That’s a problem when the network fails and the client retries.
The standard fix is the Idempotency-Key header. Stripe popularised it; it’s now a widely understood convention:
POST /payments
Idempotency-Key: 3f8b9e2a-1c5d-4a7e-9f01-7a3e2b1c4d5f
Content-Type: application/json
{ "amount": 1000, "currency": "USD", "to": "acct_42" }
The server:
- Stores the response keyed by
(client, Idempotency-Key)on the first call. - Returns the stored response on any later call with the same key.
- Expires the key after some window (Stripe uses 24 hours).
The client generates a fresh UUID per logical operation. Retries reuse the same key.
PUT and DELETE are already idempotent by HTTP semantics; PATCH is sometimes idempotent depending on implementation; POST needs the header.
Error envelopes
Pick one shape and use it everywhere. The common ones look like this:
// A single error
{
"error": {
"code": "user_not_found",
"message": "No user with id 9999",
"details": { "id": 9999 }
}
}
// Multiple validation errors
{
"error": {
"code": "validation_failed",
"message": "The request body was invalid",
"fields": [
{ "field": "email", "code": "invalid_format" },
{ "field": "age", "code": "must_be_positive" }
]
}
}
Two principles:
- A stable machine-readable
code. Don’t make clients parse themessage.user_not_foundis a contract; “Sorry, we couldn’t find that user!” is a UI string. - A human-readable
message. Useful in logs and during integration.
And — the rule from What Is REST? — return the appropriate HTTP status code alongside. A 200 OK with {"error": "..."} in the body is the classic anti-pattern.
Field selection and expansion
For big resources, let clients ask for just what they need:
# Sparse fieldsets — return only the named fields
GET /users/42?fields=id,name,email
# Expansion — inline related resources
GET /orders/9?expand=customer,items
This is a “GraphQL-lite” feature, and it pays off when you have large objects or a chatty mobile client. Implement it later if you don’t need it yet.
Rate limiting
Public APIs need rate limits. Communicate them via headers:
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 873
X-RateLimit-Reset: 1718553600
When a client exceeds the limit, return 429 Too Many Requests with a Retry-After header telling them how long to wait. Don’t just drop them.
Timestamps and IDs
The unsexy details that prevent unsexy bugs:
- Timestamps are always ISO 8601 in UTC.
2026-06-16T10:30:00Z. Never a Unix epoch in some fields and a date string in others. - IDs are stable and immutable. Once assigned, never reuse, never change.
- Prefer opaque IDs in URLs. UUIDs or short opaque strings travel better than sequential integers, which leak business metrics.
- Use camelCase or snake_case consistently. Pick one across every endpoint.
Documentation
An undocumented API doesn’t really exist. The minimum:
- An OpenAPI (formerly Swagger) spec — machine-readable, drives clients and docs
- A getting started page with one runnable example
- A changelog with dates and breaking-change calls
Tools like Stoplight, Redoc, and ReadMe consume an OpenAPI spec and produce beautiful docs. Write the spec first; let it drive your server (or vice versa).
Try it yourself. Take a tiny API you have (or the todos one from Build Your First REST API with Express) and check it against this scorecard:
- Plural resource names, no verbs in URLs
- Right HTTP method per operation
- Right status code per response
- Consistent error envelope with
codeandmessage - Pagination on every list endpoint
- Documented filter and sort parameters
- ISO 8601 timestamps everywhere
-
Idempotency-Keyaccepted on every POST that creates resources
Each gap is a polish opportunity. Most are five-line fixes.
A few opinions worth holding
After enough APIs, these calls become reflexive:
- Start with URL versioning. It’s boring and works.
- Use cursor pagination. Even if you don’t need it yet.
- Define an error code enum on day one. Adding codes later is fine; renaming them is forever.
- Validate inputs strictly. Reject unknown fields with 400 — silent acceptance trains clients into bad habits.
- Log a correlation ID per request. Echo it back in a response header. Future-you will thank present-you.
Recap
You now know:
- Resources are nouns; methods are verbs; URLs name things, not actions
- Versioning in the URL is boring and effective; reserve major bumps for breaking changes
- Cursor pagination scales; offset/limit is fine for small lists
- Filtering, sorting, search live in the query string with documented conventions
- Idempotency-Key makes POST safely retryable
- A consistent error envelope with a stable
codeis part of your contract - Timestamps, IDs, casing, headers — the small details are most of the polish
Next steps
Two natural follow-ups: drill deeper into the status codes you’ll return — HTTP Status Codes Every API Developer Should Know — and review the verbs themselves in HTTP Methods Explained. Once those are second nature, the rest of API design is taste, documentation, and discipline.
Questions or feedback? Email codeloomdevv@gmail.com.