Back to Resources Resource

API Design Best Practices - Interactive Guide

Master REST API design with interactive examples. Learn naming conventions, error handling, versioning, pagination, and security patterns used by top tech companies.

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

GET /products List all products
GET /products/123 Get product by ID
POST /products Create a new product
PUT /products/123 Replace entire product
PATCH /products/123 Update specific fields
DELETE /products/123 Delete product

Nested Resources

GET /users/42/orders Orders for user 42
GET /orders/99/items Items in order 99
POST /orders/99/items Add item to order
Rule of Thumb: Keep nesting to 2 levels max. /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

200
OK - Request succeeded
201
Created - New resource
204
No Content - Success, no body

4xx Client Errors

400
Bad Request - Invalid input
401
Unauthorized - Not logged in
403
Forbidden - No permission
404
Not Found - Doesn't exist
409
Conflict - Already exists
422
Unprocessable - Validation failed
429
Too Many Requests - Rate limited

5xx Server Errors

500
Internal Error - Bug on server
502
Bad Gateway - Upstream failed
503
Unavailable - Overloaded/maintenance
504
Timeout - Upstream too slow
Common Mistake: Don't return 200 with an error in the body. { "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

Request
POST /api/orders
{ "product_id": "invalid", "quantity": -5 }
Response
422 Unprocessable Entity
{ "error": { "code": "VALIDATION_ERROR", "message": "Request validation failed", "details": [ { "field": "product_id", "message": "Must be a valid UUID" }, { "field": "quantity", "message": "Must be greater than 0" } ] }, "request_id": "req_abc123" }
🏷️
Error Code
Machine-readable constant like VALIDATION_ERROR, NOT_FOUND. Clients can switch on this.
💬
Message
Human-readable explanation. Can be shown to end users or logged.
📋
Details
Field-level errors for forms. Which fields failed and why.
🔍
Request ID
Unique ID for tracing. Customer support can look up the exact request.

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.

Header
Accept: application/
vnd.api.v2+json
+ Clean URLs
+ "More RESTful"
- Harder to test
Used by: GitHub (alternative)
Query Parameter
/products?version=2
+ Easy to add
- Often forgotten
- Confuses caching
Generally not recommended
When to Version: Only for breaking changes—removing fields, changing types, altering behavior. Adding optional fields? No new version needed.

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-Based
?page=3&limit=20
?offset=40&limit=20
+ Simple to implement
+ Jump to any page
- Slow on large datasets
- Inconsistent if data changes
Keyset
?after_id=123
&limit=20
+ Simple cursor variant
+ Readable parameters
- Requires stable sort key

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.

🪣
Token Bucket
Tokens refill over time. Allows bursts up to bucket size. Most flexible.
🪟
Fixed Window
100 requests per minute. Simple but allows burst at window edges.
📊
Sliding Window
Smooths out bursts. More complex but fairest distribution.

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." } }
Pro Tip: Rate limit by API key, not IP address. IPs can be shared (NAT, VPNs). Keys identify the actual client. See our Rate Limiting Deep Dive.

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

URLs
Nouns not verbs. Plural. Lowercase with hyphens. Max 2 levels nesting.
Methods
GET (read), POST (create), PUT (replace), PATCH (update), DELETE (remove).
Status Codes
Use them correctly. 2xx success, 4xx client error, 5xx server error.
Errors
Consistent format. Code + message + details + request_id.
Versioning
Use URL path (/v1/). Deprecate with headers. Document sunset dates.
Pagination
Never return unbounded lists. Cursor-based for large datasets.
Rate Limiting
Return X-RateLimit headers. 429 with Retry-After when exceeded.
Security
HTTPS only. Auth on every request. Validate all input. Log request IDs.

APIs Worth Studying

Stripe: Gold standard for developer experience. GitHub: Excellent REST + GraphQL. Twilio: Clear errors, great docs. Slack: Consistent patterns throughout.

Explore More Resources