Designing a REST API That Developers Actually Like Using
I've consumed hundreds of APIs. The good ones share these patterns. The bad ones make me want to quit coding.
1. Consistent Response Format
// ✅ GOOD: Every response has the same structure
{
"data": { ... },
"meta": {
"request_id": "req_abc123",
"timestamp": "2026-05-16T01:00:00Z"
}
}
// ❌ BAD: Inconsistent formats
// Sometimes: { "user": { ... } }
// Other times: { "result": { ... } }
// Errors: { "error": true, "message": "..." }
Why: One parser handles every response. No guessing.
2. Pagination That Doesn't Suck
// ✅ GOOD: Cursor-based pagination (for large datasets)
GET /api/users?limit=20&cursor=eyJpZCI6MTAwfQ
{
"data": [...],
"pagination": {
"cursor": "eyJpZCI6MTIwfQ", // Pass this for next page
"has_more": true,
"count": 20
}
}
// ✅ ALSO GOOD: Offset-based (for small datasets)
GET /api/users?page=2&limit=20
{
"data": [...],
"pagination": {
"page": 2,
"limit": 20,
"total": 347,
"total_pages": 18
}
}
// ❌ BAD: No pagination at all
// Returns ALL records → crashes on large datasets
Rule of thumb: Cursor-based for >1000 records, offset-based for smaller sets.
3. Proper HTTP Status Codes
| Code | When to Use | Example |
|---|---|---|
| 200 | Successful GET/PUT/PATCH | {"data": user} |
| 201 | Resource created | {"data": user, "location": "/users/123"} |
| 204 | Deleted successfully | (empty body) |
| 400 | Bad request | {"error": "email is invalid"} |
| 401 | Not authenticated | {"error": "missing token"} |
| 403 | Authenticated but not allowed | {"error": "admin only"} |
| 404 | Not found | {"error": "user 999 not found"} |
| 409 | Conflict | {"error": "email already registered"} |
| 422 | Validation failed | {"errors": {"name": "required"}} |
| 429 | Rate limited | {"retry_after": 60} |
| 500 | Server error | {"error": "internal error", "request_id": "..."} |
4. Filtering, Sorting, and Searching
# Filter: exact match
GET /api/posts?status=published&author_id=123
# Filter: range
GET /api/orders?created_from=2026-01-01&created_to=2026-05-16
# Sort: field and direction
GET /api/posts?sort=-created_at,title # desc by date, then asc by title
# Search: full-text
GET /api/posts?q=nodejs+tutorial
# Field selection: reduce payload size
GET /api/users?fields=id,name,email
# Include related resources
GET /api/posts?include=author,tags,comments
5. Version Your API
# URL versioning (most common)
/api/v1/users
/api/v2/users
# Header versioning (cleaner URLs)
Accept: application/vnd.myapp.v2+json
GET /api/users
Support old versions for at least 6 months after releasing v2.
6. Error Responses Developers Can Use
// ❌ USELESS error response
{
"error": "Something went wrong"
}
// ✅ HELPFUL error response
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{
"field": "email",
"issue": "Invalid email format",
"value": "not-an-email"
},
{
"field": "password",
"issue": "Must be at least 8 characters",
"value": "[REDACTED]"
}
],
"docs_url": "https://docs.example.com/errors/VALIDATION_ERROR",
"request_id": "req_abc123"
}
}
Every error should tell the developer: what went wrong, where, why, and how to fix it.
7. Async Operations
# Start long-running task
POST /api/reports/generate
Status: 202 Accepted
{
"data": {
"id": "job_abc123",
"status": "pending",
"created_at": "2026-05-16T01:00:00Z"
},
"_links": {
"self": "/api/jobs/job_abc123",
"cancel": "/api/jobs/job_abc123/cancel"
}
}
# Check status
GET /api/jobs/job_abc123
{
"data": {
"id": "job_abc123",
"status": "completed",
"result_url": "/api/reports/rpt_456"
}
}
8. Webhooks (Push, Don't Pull)
// Instead of making clients poll:
// POST /api/webhook/register
// { "events": ["order.created", "payment.completed"], "url": "https://your-app.com/webhook" }
// You push events to them:
app.post('/webhook', async (req, res) => {
const event = req.headers['x-event-type'];
const signature = req.headers['x-signature']; // Verify!
switch (event) {
case 'order.created':
await handleNewOrder(req.body);
break;
case 'payment.completed':
await handlePayment(req.body);
break;
}
res.json({ received: true });
});
9. Rate Limiting Headers
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 943
X-RateLimit-Reset: 1715854800
Retry-After: 60
Always include these headers. Let clients handle rate limits gracefully instead of failing hard.
10. OpenAPI/Swagger Spec
# openapi.yaml — your API's contract
openapi: 3.1.0
info:
title: My API
version: 1.0.0
contact:
email: support@example.com
paths:
/users:
get:
summary: List users
parameters:
- name: page
in: query
schema:
type: integer
default: 1
responses:
'200':
description: User list
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/User'
pagination:
$ref: '#/components/schemas/Pagination'
components:
schemas:
User:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
email:
type: string
format: email
Benefits: Auto-generate docs, client SDKs, tests, and mock servers from one spec file.
The Complete Node.js Example
const express = require('express');
const z = require('zod');
const app = express();
app.use(express.json());
// Zod schemas as single source of truth
const createUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['user', 'admin']).default('user'),
});
// POST /api/v1/users
app.post('/api/v1/users', async (req, res, next) => {
try {
// Validate input
const body = createUserSchema.parse(req.body);
// Create user
const user = await User.create(body);
// Return consistent response
res.status(201).json({
data: serialize(user),
meta: { request_id: req.id },
});
} catch (err) {
if (err instanceof z.ZodError) {
return res.status(422).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: err.errors.map(e => ({
field: e.path.join('.'),
issue: e.message,
})),
},
});
}
next(err);
}
});
// GET /api/v1/users with filtering/pagination
app.get('/api/v1/users', async (req, res) => {
const { page = 1, limit = 20, sort = '-created_at', q } = req.query;
const result = await User.findAndCountAll({
where: buildWhereClause(q),
order: parseSort(sort),
limit: Math.min(limit, 100),
offset: (page - 1) * limit,
});
res.json({
data: result.rows.map(serialize),
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: result.count,
total_pages: Math.ceil(result.count / limit),
},
});
});
// Global error handler
app.use((err, req, res, _next) => {
console.error(`[ERR] ${req.id}:`, err);
res.status(err.statusCode || 500).json({
error: {
code: err.code || 'INTERNAL_ERROR',
message: process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message,
...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
},
meta: { request_id: req.id },
});
});
The API Checklist
- [ ] Consistent response envelope (
{ data, meta }) - [ ] Cursor-based pagination for large datasets
- [ ] Correct HTTP status codes
- [ ] Request validation with clear error messages
- [ ] Rate limiting with informative headers
- [ ] API versioning in URL
- [ ] OpenAPI spec (up-to-date!)
- [ ] Webhook support for async events
- [ ] Request IDs for debugging
- [ ] CORS configured properly
- [ ] Authentication docs (Bearer token, OAuth2)
- [ ] SDK/client library available
- [ ] Sandbox/test environment
- [ ] Changelog / migration guide between versions
What's the best/worst API you've used? Share in the comments.
Follow @armorbreak for more backend development guides.
Top comments (0)