backend

REST API Design Best Practices: The Complete Guide (2026)

REST API design best practices covering URL naming, HTTP status codes, versioning, pagination, error responses, security, and request/response design. With real HTTP examples.

By Akash Sharma·14 min read
#api
#rest
#backend
#http
#system design
#web development
#best practices

REST API design best practices are a set of conventions that make your API consistent, predictable, and easy to use. The core rules: use nouns not verbs in URLs, use HTTP methods to express actions, return correct status codes, version from day one, paginate all collections, and always return structured error responses. Everything else is detail.

After building APIs in Python and Go for 6 years — some that developers loved, some that generated a lot of confused Slack messages — here is everything that actually makes a difference.


URL Design and Resource Naming

The URL identifies what you're working with. The HTTP method tells you what action to take. This split is the foundation of REST.

Nouns, Not Verbs

plaintext
# Bad — verbs leak into URLs
GET  /getUser/123
POST /createOrder
POST /deleteProduct/456
POST /sendEmailToUser/123
 
# Good — nouns in URLs, HTTP methods express the action
GET    /users/123
POST   /orders
DELETE /products/456
POST   /users/123/email-notifications

Always Use Plural Nouns for Collections

Consistency beats cleverness. If /users returns a list, then /users/123 returns one member of that list. Always plural.

plaintext
GET    /users           → list all users
GET    /users/123       → get user 123
POST   /users           → create a new user
PUT    /users/123       → replace user 123
PATCH  /users/123       → partial update of user 123
DELETE /users/123       → delete user 123

Nesting Depth: Maximum Two Levels

Nested URLs express relationships between resources.

plaintext
GET /users/123/orders         → orders belonging to user 123
GET /users/123/orders/456     → specific order by user 123

Stop at two levels. /companies/1/departments/2/teams/3/members/4 is hard to read, hard to remember, and hard to maintain. For deeper relationships, use query parameters:

plaintext
# Instead of /companies/1/departments/2/teams/3/members/4
GET /members?company_id=1&department_id=2&team_id=3

Query Params vs Path Params

Use path parameters for identifying a specific resource:

plaintext
GET /users/123          ← 123 identifies which user
GET /orders/abc-456     ← abc-456 identifies which order

Use query parameters for filtering, sorting, searching, pagination, and optional modifiers:

plaintext
GET /users?status=active&country=IN
GET /users?sort=created_at&order=desc
GET /users?search=akash&limit=20&cursor=xyz
GET /products?min_price=100&max_price=500&category=electronics

A quick rule: if removing the parameter returns a different resource, it's a path param. If it filters or shapes the result, it's a query param.


Use the Right HTTP Method

MethodUse forIdempotent?Body?
GETFetch dataYesNo
POSTCreate new resourceNoYes
PUTReplace entire resourceYesYes
PATCHPartial updateUsually yesYes
DELETERemove resourceYesRarely

Idempotent means calling it multiple times produces the same result. PUT /users/123 with the same payload always ends with the same state. POST /users creates a new user on every call.


HTTP Status Codes: The Complete Guide

Status codes are the first thing a client reads. Using them correctly lets clients handle errors without parsing your response body. Using them wrong breaks every HTTP client, SDK, and monitoring tool pointed at your API.

2xx — Success

plaintext
200 OK           → successful GET, PUT, PATCH
201 Created      → successful POST — return the created resource
202 Accepted     → request accepted, processing async (jobs, exports)
204 No Content   → successful DELETE or action with nothing to return

3xx — Redirection

plaintext
301 Moved Permanently  → resource has a new permanent URL (update your bookmarks)
302 Found              → temporary redirect
304 Not Modified       → ETag/conditional GET — client cache is still valid

4xx — Client Errors

plaintext
400 Bad Request          → malformed request, invalid JSON, missing required field
401 Unauthorized         → no auth token, or token is invalid/expired
403 Forbidden            → authenticated but not allowed to do this
404 Not Found            → resource doesn't exist (or you're hiding it intentionally)
405 Method Not Allowed   → tried DELETE on a read-only endpoint
409 Conflict             → duplicate resource (duplicate email on signup)
410 Gone                 → resource existed but was permanently deleted
422 Unprocessable Entity → valid JSON, but fails business validation rules
429 Too Many Requests    → rate limited — include Retry-After header

5xx — Server Errors

plaintext
500 Internal Server Error  → something broke on your side
502 Bad Gateway            → upstream service returned invalid response
503 Service Unavailable    → server is down or overloaded
504 Gateway Timeout        → upstream service timed out

Common Mistakes to Avoid

Never return 200 for errors:

http
# Bad — client thinks the request succeeded
HTTP/1.1 200 OK
Content-Type: application/json
 
{
  "success": false,
  "error": "User not found"
}
 
# Good
HTTP/1.1 404 Not Found
Content-Type: application/json
 
{
  "error": {
    "code": "USER_NOT_FOUND",
    "message": "No user found with ID 123"
  }
}

Use 401 vs 403 correctly:

  • 401 Unauthorized — the client is not authenticated (no token, expired token)
  • 403 Forbidden — the client is authenticated but lacks permission

Use 422 for validation errors, not 400:

  • 400 Bad Request — the request structure is broken (invalid JSON, missing Content-Type)
  • 422 Unprocessable Entity — valid request format, but the values fail validation

API Versioning Strategies

Breaking changes are inevitable. Versioning lets you evolve the API without breaking existing clients.

URL Versioning (Recommended)

plaintext
/v1/users
/v2/users

Explicit, easy to test in a browser or curl, easy to route in a load balancer. This is the standard used by Stripe, Twilio, and most production APIs.

http
GET /v1/users/123 HTTP/1.1
Host: api.example.com

Header Versioning

http
GET /users/123 HTTP/1.1
Host: api.example.com
Accept: application/vnd.example.v2+json

Keeps URLs clean. Harder to test in a browser, harder to cache, and confusing when you need to share a URL with a specific version. Avoid unless you have a strong reason.

How to Deprecate Old Versions

  1. Add a Deprecation header to all v1 responses as a warning:
http
HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 01 Jan 2027 00:00:00 GMT
Link: <https://api.example.com/v2/users>; rel="successor-version"
  1. Document the migration path in your API changelog.
  2. Email all developers who called v1 in the last 90 days.
  3. Keep v1 running until usage drops to zero (or your SLA expires).
  4. Return 410 Gone on the sunset date, with a pointer to v2.

What counts as a breaking change (requires a new version):

  • Removing a field from a response
  • Renaming a field
  • Changing a field type (stringinteger)
  • Removing an endpoint
  • Changing authentication scheme

What does not require a new version:

  • Adding new optional fields to responses
  • Adding new optional query parameters
  • Adding new endpoints

Pagination, Filtering, Sorting

Never return an unbounded collection. Always paginate.

Offset Pagination

Simple to implement and understand. Gets slow on large tables because the database still scans all skipped rows.

http
GET /users?page=2&limit=20 HTTP/1.1
json
{
  "data": [...],
  "pagination": {
    "page": 2,
    "limit": 20,
    "total": 1540,
    "total_pages": 77
  }
}

Use for small datasets (under ~50,000 rows) or when users need to jump to a specific page number.

Cursor-Based Pagination

Faster on large datasets. No skipped row scan. Handles concurrent inserts correctly (no duplicates or skipped items when new records are added mid-pagination).

http
GET /users?cursor=eyJpZCI6MTIzfQ&limit=20 HTTP/1.1
json
{
  "data": [...],
  "pagination": {
    "next_cursor": "eyJpZCI6MTQzfQ",
    "has_more": true,
    "limit": 20
  }
}

The cursor is usually a base64-encoded pointer to the last item (e.g., the id or a (created_at, id) tuple). Clients cannot jump to page 5 directly — they must walk forward. This is fine for feeds and infinite scroll, not fine for admin UIs with page number navigation.

Link Headers (RFC 5988)

The HTTP standard way to communicate pagination links, used by the GitHub API:

http
HTTP/1.1 200 OK
Link: <https://api.example.com/users?cursor=abc&limit=20>; rel="next",
      <https://api.example.com/users?cursor=xyz&limit=20>; rel="prev"

Filtering and Sorting

plaintext
# Filtering — use query params
GET /users?status=active&role=admin&country=IN
 
# Sorting
GET /users?sort=created_at&order=desc
GET /products?sort=price&order=asc
 
# Multiple sort fields
GET /users?sort=last_name,first_name&order=asc,asc
 
# Search
GET /users?search=akash
GET /products?q=bluetooth+headphones

Keep filter parameter names consistent across endpoints. If status filters on /users, it should mean the same thing on /orders.


Error Response Design

Consistent error responses are as important as consistent success responses. Every client — mobile, frontend, internal service — needs to parse your errors. If every endpoint returns errors differently, every client needs custom parsing logic for every endpoint.

Consistent Error Schema

json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed for 2 fields",
    "details": [
      {
        "field": "email",
        "message": "Must be a valid email address",
        "value": "not-an-email"
      },
      {
        "field": "age",
        "message": "Must be at least 18",
        "value": 15
      }
    ]
  }
}
  • code — machine-readable string your clients can switch on. Never changes, even if the message text changes.
  • message — human-readable explanation.
  • details — array of field-level errors for validation failures.
json
// Not found
{
  "error": {
    "code": "USER_NOT_FOUND",
    "message": "No user found with ID 123"
  }
}
 
// Rate limited
{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Too many requests. Try again in 30 seconds.",
    "retry_after": 30
  }
}

RFC 7807 Problem Details

The IETF standard for HTTP error bodies. Gaining adoption in newer APIs (used by many Java Spring and ASP.NET APIs):

http
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
 
{
  "type": "https://api.example.com/errors/validation-error",
  "title": "Validation Failed",
  "status": 422,
  "detail": "The request body contained 2 validation errors",
  "instance": "/users",
  "errors": [
    { "field": "email", "message": "Invalid email format" }
  ]
}

RFC 7807 is worth adopting if you're building a new API — it's becoming the standard for OpenAPI-based tooling. For existing APIs, using a consistent custom schema is better than migrating mid-stream.


API Security Basics

Authentication

Bearer Token (most common):

http
GET /users/123 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

API Key (for service-to-service):

http
GET /data HTTP/1.1
Host: api.example.com
X-API-Key: sk_live_abc123xyz

Never put API keys in query parameters (/data?api_key=secret) — they end up in server logs, browser history, and referrer headers.

HTTPS Only

Always enforce HTTPS. Redirect HTTP to HTTPS at the load balancer level. Return 301 Moved Permanently — not a soft redirect that allows HTTP to work. Never transmit tokens over plain HTTP.

Rate Limiting

Include rate limit information in response headers so clients can self-throttle:

http
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1750000000

When the limit is exceeded:

http
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1750000000

CORS

Set CORS headers explicitly. Do not use wildcard * in production for authenticated APIs:

http
Access-Control-Allow-Origin: https://yourapp.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400

For public read-only APIs (weather, public data), * is fine. For anything with authentication, pin to specific origins.


Request and Response Design

When to Use Request Body vs Query Params

Use caseHow
Creating/updating a resourceRequest body (JSON)
Filtering, searching, sortingQuery params
PaginationQuery params
Single identifierPath param
AuthenticationHeader (Authorization)

Never put sensitive data in query params. Never put large datasets in query params.

Envelope vs Direct Response

Direct response — return the resource itself:

json
GET /users/123
 
{
  "id": 123,
  "name": "Akash Sharma",
  "email": "akash@example.com"
}

Envelope — wrap in a data key:

json
GET /users/123
 
{
  "data": {
    "id": 123,
    "name": "Akash Sharma",
    "email": "akash@example.com"
  }
}

Envelopes are useful when you need to attach metadata (pagination, request ID, rate limit info) alongside the resource. Direct responses are simpler for single-resource endpoints. Pick one pattern and use it consistently across all endpoints.

Partial Responses (Fields Param)

For mobile clients or bandwidth-sensitive use cases, let the client request only the fields they need:

plaintext
GET /users/123?fields=id,name,email
json
{
  "id": 123,
  "name": "Akash Sharma",
  "email": "akash@example.com"
}

This pattern is used by Google APIs and the Facebook Graph API. It reduces payload size and avoids over-fetching without requiring GraphQL.


Key Takeaways

  • Use nouns in URLs, HTTP methods for actions. Never put verbs in URLs.
  • Always return the correct HTTP status code — never 200 for errors.
  • Error responses need a consistent schema: code, message, details.
  • Version from day one — URL versioning (/v1/) is the safest default.
  • Paginate all collections — cursor-based for large datasets, offset for small.
  • Nest at most 2 levels deep — use query params for deeper relationships.
  • Put API keys and tokens in headers, never in query params.
  • Include rate limit headers on every response so clients can self-throttle.
  • Be consistent — if filtering works one way on /users, it works the same on /orders.

The best API is the one developers can use without reading the documentation.


FAQ

What is REST and what makes a good REST API?

REST (Representational State Transfer) is an architectural style for building HTTP APIs. It uses standard HTTP methods (GET, POST, PUT, PATCH, DELETE) to operate on resources identified by URLs. A good REST API is predictable: given the URL and method, a developer can guess what it does. Concretely, that means consistent URL structure, correct HTTP status codes, structured error responses, versioning, and proper authentication. If a developer can use your API without referencing the docs every 5 minutes, it's a good REST API.

Should REST API URLs use nouns or verbs?

Always nouns. The HTTP method is the verb. GET /users/123 reads as "GET the user with ID 123" — GET is the verb, /users/123 is the noun. Writing /getUser/123 duplicates the verb concept and breaks HTTP semantics. The only exception is action-oriented endpoints that don't map cleanly to a resource, like /users/123/activate or /payments/123/refund — even then, treat these as sub-resources rather than verbs.

What HTTP status code should I return for validation errors?

422 Unprocessable Entity. Reserve 400 Bad Request for structurally broken requests (malformed JSON, wrong Content-Type). Use 422 when the request is well-formed but the values fail validation (invalid email format, age below minimum, required field missing). Always include a response body that lists which fields failed and why — a bare 422 with no body is as useless as 400 Bad Request.

How should I version my REST API?

URL versioning (/v1/users, /v2/users) is the most practical approach. It's explicit, easy to test with curl, easy to route in nginx or a load balancer, and immediately visible in logs. Start with /v1/ on day one, even before you have any breaking changes planned — retrofitting versioning later is painful. When you do introduce v2, keep v1 running, add a Deprecation + Sunset header to v1 responses, and give clients a documented migration window before switching off.

What is the best pagination strategy for a REST API?

Cursor-based pagination for large or frequently-updated datasets; offset pagination for small datasets or when users need to jump to a specific page. Cursor-based is more complex to implement but is faster at scale (no full table scan), handles concurrent writes without gaps or duplicates, and is what large-scale APIs like Twitter, Slack, and Stripe use. For admin dashboards with page numbers or small tables under 50,000 rows, offset pagination is simpler and completely fine.

How do I design consistent error responses in a REST API?

Define one error schema and use it everywhere. At minimum: a machine-readable code string (e.g. "VALIDATION_ERROR", "NOT_FOUND"), a human-readable message, and an optional details array for field-level errors. The code field is what clients actually switch on in code — it must never change between versions. If you want to follow an industry standard, look at RFC 7807 Problem Details (application/problem+json), which is gaining adoption in OpenAPI-based tooling. The important rule: every 4xx and 5xx response must have a body. A bare status code with no body forces clients to hard-code magic numbers.

What is HATEOAS and do I need it?

HATEOAS (Hypermedia As The Engine Of Application State) is a REST constraint where API responses include links to related actions, so clients discover what they can do next from the response itself rather than from documentation. In practice, almost no production REST API fully implements HATEOAS. It adds response payload size, complicates client code, and the benefits (discoverability, loose coupling) are largely theoretical for most use cases. The GitHub API and a few others include _links objects in responses as a partial HATEOAS implementation. For most APIs: skip it. Focus on consistent URLs, correct status codes, and good documentation instead.


Related reading: Rate Limiting Your API · API Gateway Pattern

Enjoyed this article?

Get weekly insights on backend architecture, system design, and Go programming.