REST API Design Best Practices: Build Developer-Friendly APIs
Design REST APIs developers love using resource-based URLs, consistent error handling, versioning, pagination, and OpenAPI documentation for scalable integrations.

TL;DR
- Use resource-based URLs (
/users/123, not/getUser?id=123); HTTP verbs express actions. - Return structured JSON with status codes; errors include
error.code,error.message,error.details. - Version APIs via URL (
/v1/users) or header (Accept: application/vnd.api+json; version=1).
Jump to URL design · Jump to Request/response format · Jump to Error handling · Jump to Versioning
# REST API Design Best Practices: Build Developer-Friendly APIs
Your API is your product's interface to the world -poor design creates friction, support burden, and limits adoption. These REST API design best practices create intuitive, scalable APIs developers actually enjoy using.
Key takeaways - Resource-based URLs + HTTP verbs = self-documenting API surface. - Consistent error responses (codes, messages, details) reduce integration time 50%. - OpenAPI documentation enables auto-generated SDKs and interactive testing.
URL design principles
Use nouns for resources, verbs for actions
Pattern: /resources/{id}/subresources/{id}
Good URLs:
GET /users # List users
GET /users/123 # Get user 123
POST /users # Create user
PUT /users/123 # Update user 123
DELETE /users/123 # Delete user 123
GET /users/123/posts # Get posts by user 123Bad URLs:
GET /getUser?id=123 # Verb in URL (use GET /users/123)
POST /users/delete # DELETE method exists
GET /user-posts?userId=123 # Use /users/123/postsPlural vs singular
Recommendation: Always plural (/users, /posts), even for singleton resources.
Why: Consistency. Exception: Singleton resources like /me (current user), /status (health check).
Query parameters for filtering/sorting
GET /users?status=active&sort=created_at:desc&limit=50&offset=100Standard params:
- Filtering:
?status=active&role=admin - Sorting:
?sort=created_at:desc,name:asc - Pagination:
?limit=50&offset=100or?cursor=abc123 - Field selection:
?fields=id,name,email(reduce payload)
<figure>
<svg role="img" aria-label="REST API URL structure" viewBox="0 0 720 160" xmlns="http://www.w3.org/2000/svg">
<rect width="720" height="160" fill="#0f172a" />
<text x="30" y="40" fill="#10b981" font-size="18">REST API URL Structure</text>
<rect x="60" y="70" width="140" height="60" rx="8" fill="#22d3ee" />
<text x="80" y="105" fill="#0f172a" font-size="12">/users (collection)</text>
<rect x="230" y="70" width="140" height="60" rx="8" fill="#a855f7" />
<text x="250" y="105" fill="#fff" font-size="12">/users/123 (item)</text>
<rect x="400" y="70" width="180" height="60" rx="8" fill="#10b981" />
<text x="420" y="105" fill="#0f172a" font-size="11">/users/123/posts (nested)</text>
</svg>
<figcaption>Resource-based URLs: collections, individual items, nested subresources.</figcaption>
</figure>
"The developer experience improvements we've seen from AI tools are the most significant since IDEs and version control. This is a permanent shift in how software gets built." - Emily Freeman, VP of Developer Relations at AWS
Request/response format
Standard JSON structure
Success response (200 OK):
{
"data": {
"id": "usr_123",
"name": "Alice Chen",
"email": "alice@example.com",
"created_at": "2025-04-25T10:30:00Z"
}
}Collection response (200 OK):
{
"data": [
{ "id": "usr_123", "name": "Alice Chen" },
{ "id": "usr_456", "name": "Bob Smith" }
],
"meta": {
"total": 1247,
"limit": 50,
"offset": 100
},
"links": {
"next": "/users?limit=50&offset=150",
"prev": "/users?limit=50&offset=50"
}
}Why wrap in `data`: Allows adding meta, links, included fields without breaking clients.
HTTP status codes
| Code | Meaning | Use case |
|---|---|---|
| 200 OK | Success | GET, PUT, PATCH succeeded |
| 201 Created | Resource created | POST succeeded |
| 204 No Content | Success, no body | DELETE succeeded |
| 400 Bad Request | Client error | Validation failed, malformed JSON |
| 401 Unauthorized | Auth failed | Missing/invalid API key |
| 403 Forbidden | Insufficient permissions | User lacks access to resource |
| 404 Not Found | Resource doesn't exist | GET /users/999 (no user 999) |
| 429 Too Many Requests | Rate limit exceeded | Client hit 100 req/min limit |
| 500 Internal Server Error | Server error | Unhandled exception |
Rule: 2xx = success, 4xx = client error, 5xx = server error.
Error handling framework
Consistent error response
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Email address is invalid",
"details": [
{
"field": "email",
"issue": "Must be valid email format"
}
],
"request_id": "req_abc123"
}
}Fields:
- `code`: Machine-readable (e.g.,
RATE_LIMIT_EXCEEDED,RESOURCE_NOT_FOUND). - `message`: Human-readable description.
- `details`: Field-level validation errors (for 400 responses).
- `request_id`: Trace logs for debugging.
Common error codes
| Code | HTTP Status | Example message |
|---|---|---|
INVALID_REQUEST | 400 | "Missing required field: name" |
UNAUTHORIZED | 401 | "API key invalid or expired" |
FORBIDDEN | 403 | "Insufficient permissions to delete user" |
NOT_FOUND | 404 | "User usr_123 not found" |
RATE_LIMIT_EXCEEDED | 429 | "Rate limit: 100 requests/minute exceeded" |
INTERNAL_ERROR | 500 | "An unexpected error occurred" |
Example (Stripe-style):
{
"error": {
"type": "invalid_request_error",
"code": "parameter_invalid_integer",
"message": "Invalid integer: abc",
"param": "limit"
}
}For integration best practices, see /blog/zapier-vs-make-vs-n8n-ai-ops.
API versioning strategy
Option 1: URL versioning
GET /v1/users/123
GET /v2/users/123 # Breaking change: response schema differsPros: Explicit, easy to route, clear deprecation path.
Cons: Version in every URL; harder to evolve incrementally.
When to use: Major versions (v1, v2); breaking changes (field removed, renamed).
Option 2: Header versioning
GET /users/123
Accept: application/vnd.myapi.v1+jsonPros: Clean URLs, version decoupled from resource.
Cons: Less discoverable; clients must set headers.
When to use: SaaS platforms with long-lived API contracts.
Option 3: No versioning (additive changes only)
Strategy: Never break compatibility; only add fields, endpoints, query params.
Example:
- ✅ Add new field
phone_number(clients ignore unknown fields). - ❌ Rename
email→email_address(breaking).
When to use: Internal APIs, rapid iteration pre-GA.
Recommendation: Start with URL versioning (/v1); transition to header-based for mature APIs.
<figure>
<svg role="img" aria-label="API versioning lifecycle" viewBox="0 0 680 180" xmlns="http://www.w3.org/2000/svg">
<rect width="680" height="180" fill="#0f172a" />
<text x="30" y="40" fill="#34d399" font-size="18">API Versioning Lifecycle</text>
<rect x="60" y="80" width="120" height="70" rx="12" fill="#22d3ee" />
<text x="80" y="120" fill="#0f172a" font-size="12">v1 (stable)</text>
<rect x="210" y="80" width="120" height="70" rx="12" fill="#a855f7" />
<text x="230" y="120" fill="#fff" font-size="12">v2 (current)</text>
<rect x="360" y="80" width="120" height="70" rx="12" fill="#10b981" />
<text x="380" y="120" fill="#0f172a" font-size="12">v3 (beta)</text>
<rect x="510" y="80" width="120" height="70" rx="12" fill="#e11d48" opacity="0.5" />
<text x="530" y="120" fill="#fff" font-size="11">v1 (deprecated)</text>
</svg>
<figcaption>Maintain 2 versions simultaneously; deprecate old versions with 12-month notice.</figcaption>
</figure>
Pagination & rate limiting
Pagination strategies
Offset-based:
GET /users?limit=50&offset=100- Pros: Simple, stateless.
- Cons: Slow for large offsets; inconsistent if data changes during pagination.
Cursor-based:
GET /users?limit=50&cursor=usr_123_encoded- Pros: Consistent results, performant for large datasets.
- Cons: Can't jump to arbitrary page.
Recommendation: Offset for small datasets (<10K records), cursor for large or real-time data.
Rate limiting headers
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 73
X-RateLimit-Reset: 1714041600When limit exceeded:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Rate limit: 100 requests/minute exceeded. Retry after 60 seconds."
}
}Tiers:
- Free: 100 req/min
- Pro: 1,000 req/min
- Enterprise: Custom limits
Documentation with OpenAPI
Why OpenAPI (Swagger):
- Auto-generate SDKs (TypeScript, Python, Go).
- Interactive API explorer (Swagger UI, Redocly).
- Validation: Test requests match schema.
Example OpenAPI spec:
openapi: 3.0.0
info:
title: Users API
version: 1.0.0
paths:
/v1/users:
get:
summary: List users
parameters:
- name: limit
in: query
schema:
type: integer
default: 50
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/User'
components:
schemas:
User:
type: object
properties:
id:
type: string
name:
type: string
email:
type: stringTools: Stoplight Studio (visual editor), Redocly (docs hosting), Postman (import OpenAPI).
Call-to-action (API design) Audit existing API endpoints against these principles; refactor top 3 inconsistencies before adding new endpoints.
FAQs
REST vs GraphQL -which to choose?
REST: Simpler, cacheable, better for public APIs, CRUD operations.
GraphQL: Flexible queries, reduces over-fetching, better for complex UIs with varied data needs.
Recommendation: Start with REST; add GraphQL if frontend has 10+ bespoke queries.
Should you use HATEOAS (hypermedia links)?
HATEOAS example:
{
"data": {
"id": "usr_123",
"name": "Alice"
},
"links": {
"self": "/users/123",
"posts": "/users/123/posts"
}
}Pros: Discoverability, decouples clients from URL construction.
Cons: Verbose, rarely implemented fully.
Recommendation: Include links for pagination (next, prev); skip for every resource (overkill).
How to handle file uploads?
Multipart form-data:
POST /users/123/avatar
Content-Type: multipart/form-data
--boundary
Content-Disposition: form-data; name="file"; filename="avatar.jpg"
...Alternative: Direct upload to S3, return URL to API:
- GET
/uploads/presigned-url→{url, fields} - POST to S3 with file
- POST
/users/123/avatarwith{url: "s3://..."}
What about webhooks?
Design: POST to customer-configured URL with event payload.
Best practices:
- Include
event.type(user.created,payment.succeeded). - Retry failed webhooks (exponential backoff: 1s, 5s, 25s, 2m, 10m).
- Sign payloads (HMAC) for verification.
- Support webhook logs (customer can debug delivery).
Summary and next steps
Design REST APIs with resource-based URLs, consistent JSON responses, structured errors, versioning, and OpenAPI documentation for excellent developer experience.
Next steps
- Define URL schema for your core resources (users, posts, etc.).
- Standardise error response format and document common error codes.
- Generate OpenAPI spec and publish interactive docs (Swagger UI, Redocly).
Internal links
- /blog/typescript-vs-python-startup-stack
- /blog/database-postgres-vs-mongodb-startups
- /blog/zapier-vs-make-vs-n8n-ai-ops
- /blog/vercel-vs-netlify-vs-railway-deployment
External references
- Stripe API Design – gold standard for developer experience.
- Microsoft REST API Guidelines – comprehensive best practices.
- OpenAPI Specification – API documentation standard.
Crosslinks
More from the blog
OpenHelm vs runCLAUDErun: Which Claude Code Scheduler Is Right for You?
A direct comparison of the two most popular Claude Code schedulers, how each works, what each costs, and which fits your workflow.
Claude Code vs Cursor Pro: Real Developer Cost Comparison
An honest look at what developers actually spend on Claude Code, Cursor Pro, and GitHub Copilot, and how to get the most from each.