Design APIs that developers love. This guide covers REST best practices— naming conventions, error handling, versioning, and security patterns used by Stripe, GitHub, and Twilio.
How to use: Toggle between Visual to understand patterns and Code to implement them. Try the interactive API tester to see requests in action.
REST Fundamentals
REST organizes your API around resources (nouns), not actions (verbs). Each resource has a predictable URL, and HTTP methods define what you do with it.
Resource-Based URLs
Nested Resources
/users/42/orders/99/items/5/reviews is too deep. Use /reviews?item_id=5 instead.
Express.js Routes
const express = require('express');
const router = express.Router();
// Collection endpoints
router.get('/products', listProducts); // GET all
router.post('/products', createProduct); // CREATE
// Single resource endpoints
router.get('/products/:id', getProduct); // GET one
router.put('/products/:id', replaceProduct); // REPLACE
router.patch('/products/:id', updateProduct); // PARTIAL UPDATE
router.delete('/products/:id', deleteProduct); // DELETE
// Nested resources
router.get('/users/:userId/orders', getUserOrders);
router.post('/orders/:orderId/items', addOrderItem);Controller Example
async function getProduct(req, res) {
const { id } = req.params;
const product = await Product.findById(id);
if (!product) {
return res.status(404).json({
error: {
code: 'PRODUCT_NOT_FOUND',
message: `Product ${id} does not exist`
}
});
}
res.json({ data: product });
}Naming Conventions
| Rule | Good | Bad |
|---|---|---|
| Use nouns, not verbs | /products | /getProducts |
| Plural for collections | /users/123 | /user/123 |
| Lowercase with hyphens | /order-items | /orderItems, /order_items |
| No trailing slashes | /products | /products/ |
| Filter via query params | /products?category=electronics | /products/electronics |
HTTP Status Codes
Status codes tell clients what happened. Use them correctly—they're the first thing developers check when debugging.
2xx Success
4xx Client Errors
5xx Server Errors
{ "status": 200, "error": "Not found" } is wrong. Use proper HTTP status codes.
Error Handling
Good error responses help developers fix issues fast. Include a code, message, and actionable details.
Error Response Structure
Consistent Error Class
class APIError extends Error {
constructor(statusCode, code, message, details = null) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.details = details;
}
toJSON() {
return {
error: {
code: this.code,
message: this.message,
...(this.details && { details: this.details })
}
};
}
}
// Predefined errors
const Errors = {
notFound: (resource) => new APIError(
404, 'NOT_FOUND', `${resource} not found`
),
validation: (details) => new APIError(
422, 'VALIDATION_ERROR', 'Validation failed', details
),
unauthorized: () => new APIError(
401, 'UNAUTHORIZED', 'Authentication required'
),
forbidden: () => new APIError(
403, 'FORBIDDEN', 'Insufficient permissions'
),
rateLimited: () => new APIError(
429, 'RATE_LIMITED', 'Too many requests'
)
};Global Error Handler
// Express error middleware (must be last)
app.use((err, req, res, next) => {
const requestId = req.headers['x-request-id'] || generateId();
// Log for debugging (with stack trace)
console.error({
requestId,
error: err.message,
stack: err.stack,
path: req.path,
method: req.method
});
// Send clean response to client
const statusCode = err.statusCode || 500;
const response = err instanceof APIError
? err.toJSON()
: { error: { code: 'INTERNAL_ERROR', message: 'Something went wrong' }};
response.request_id = requestId;
res.status(statusCode).json(response);
});API Versioning
Breaking changes happen. Versioning lets you evolve your API without breaking existing clients.
/v2/products
vnd.api.v2+json
Version Implementation
// Version router
const express = require('express');
const app = express();
// Mount versioned routers
app.use('/v1', require('./routes/v1'));
app.use('/v2', require('./routes/v2'));
// Redirect unversioned to latest
app.use('/products', (req, res) => {
res.redirect(307, `/v2${req.url}`);
});
// Deprecation warning header
v1Router.use((req, res, next) => {
res.set('Deprecation', 'true');
res.set('Sunset', 'Sat, 1 Jan 2025 00:00:00 GMT');
next();
});Pagination
Never return unbounded lists. Pagination keeps responses fast and memory usage low.
?offset=40&limit=20
&limit=20
&limit=20
Cursor Pagination Response
{
"data": [
{ "id": "prod_124", "name": "Headphones" },
{ "id": "prod_125", "name": "Keyboard" }
],
"pagination": {
"next_cursor": "eyJpZCI6InByb2RfMTI1In0",
"has_more": true,
"limit": 20
},
"links": {
"next": "/v1/products?cursor=eyJpZCI6InByb2RfMTI1In0&limit=20"
}
}Implementation
async function listProducts(req, res) {
const limit = Math.min(parseInt(req.query.limit) || 20, 100);
const cursor = req.query.cursor;
let query = Product.find().sort({ _id: 1 }).limit(limit + 1);
if (cursor) {
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString());
query = query.where('_id').gt(decoded.id);
}
const results = await query.exec();
const hasMore = results.length > limit;
const data = hasMore ? results.slice(0, -1) : results;
const nextCursor = hasMore
? Buffer.from(JSON.stringify({ id: data[data.length - 1]._id })).toString('base64')
: null;
res.json({
data,
pagination: { next_cursor: nextCursor, has_more: hasMore, limit }
});
}Rate Limiting
Protect your API from abuse and ensure fair usage. Return clear headers so clients can back off gracefully.
Rate Limit Headers
HTTP/1.1 200 OK
X-RateLimit-Limit: 100 // Max requests per window
X-RateLimit-Remaining: 45 // Requests left in window
X-RateLimit-Reset: 1699488000 // Unix timestamp when window resets
// When exceeded:
HTTP/1.1 429 Too Many Requests
Retry-After: 30 // Seconds until they can retry
{
"error": {
"code": "RATE_LIMITED",
"message": "Rate limit exceeded. Retry in 30 seconds."
}
}Security Headers
Essential headers every API should return. Don't skip these.
// Security headers middleware
app.use((req, res, next) => {
// Prevent MIME sniffing
res.setHeader('X-Content-Type-Options', 'nosniff');
// Don't expose server info
res.removeHeader('X-Powered-By');
// Strict transport security (HTTPS only)
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
// CORS (configure for your domains)
res.setHeader('Access-Control-Allow-Origin', 'https://yourapp.com');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
// Request ID for tracing
const requestId = req.headers['x-request-id'] || generateUUID();
res.setHeader('X-Request-Id', requestId);
next();
});Quick Reference Checklist
APIs Worth Studying
Stripe: Gold standard for developer experience. GitHub: Excellent REST + GraphQL. Twilio: Clear errors, great docs. Slack: Consistent patterns throughout.