A well-designed API is the foundation of modern software systems. Whether you're building internal microservices or public APIs consumed by thousands of developers, following API design best practices ensures your API is intuitive, maintainable, and scalable. This comprehensive guide covers REST principles, versioning strategies, error handling, documentation, and security practices that lead to developer-friendly APIs.
RESTful API Design Principles
REST (Representational State Transfer) remains the most popular API architectural style. Following REST principles creates predictable, standards-based APIs that developers intuitively understand.
1. Use Nouns for Resources, Not Verbs
✅ Good:
GET /users
GET /users/123
POST /users
PUT /users/123
DELETE /users/123
❌ Bad:
GET /getUsers
GET /getUserById/123
POST /createUser
POST /updateUser/123
POST /deleteUser/1232. Use HTTP Methods Correctly
- GET: Retrieve resources (idempotent, cacheable)
- POST: Create new resources (not idempotent)
- PUT: Update entire resource (idempotent)
- PATCH: Partial update (idempotent)
- DELETE: Remove resource (idempotent)
// Express.js example
const express = require('express');
const router = express.Router();
// GET - Retrieve all users
router.get('/users', async (req, res) => {
const users = await User.findAll();
res.json(users);
});
// GET - Retrieve specific user
router.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
});
// POST - Create new user
router.post('/users', async (req, res) => {
const user = await User.create(req.body);
res.status(201)
.location(`/users/${user.id}`)
.json(user);
});
// PUT - Replace entire user
router.put('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
await user.update(req.body);
res.json(user);
});
// PATCH - Partial update
router.patch('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
await user.update(req.body, { partial: true });
res.json(user);
});
// DELETE - Remove user
router.delete('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
await user.destroy();
res.status(204).send();
});3. Use Proper HTTP Status Codes
- 200 OK: Successful GET, PUT, PATCH, or DELETE
- 201 Created: Successful POST creating a resource
- 204 No Content: Successful request with no response body
- 400 Bad Request: Invalid request data
- 401 Unauthorized: Authentication required
- 403 Forbidden: Authenticated but not authorized
- 404 Not Found: Resource doesn't exist
- 409 Conflict: Request conflicts with current state
- 422 Unprocessable Entity: Validation errors
- 429 Too Many Requests: Rate limit exceeded
- 500 Internal Server Error: Server error
- 503 Service Unavailable: Temporary unavailability
Resource Naming and URL Structure
Consistent Naming Conventions
✅ Good - Consistent, hierarchical structure:
/users
/users/123
/users/123/posts
/users/123/posts/456
/users/123/posts/456/comments
/organizations
/organizations/abc
/organizations/abc/members
❌ Bad - Inconsistent structure:
/user
/Users/123
/getUserPosts/123
/post-comments/456Use Query Parameters for Filtering and Sorting
// Filtering, sorting, pagination via query params
router.get('/users', async (req, res) => {
const {
status, // Filter by status
role, // Filter by role
sort, // Sort field
order = 'asc', // Sort order
page = 1, // Page number
limit = 20 // Items per page
} = req.query;
const query = {};
if (status) query.status = status;
if (role) query.role = role;
const offset = (page - 1) * limit;
const users = await User.findAll({
where: query,
order: sort ? [[sort, order]] : undefined,
limit: parseInt(limit),
offset: parseInt(offset)
});
const total = await User.count({ where: query });
res.json({
data: users,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / limit)
}
});
});
// Example requests:
// GET /users?status=active
// GET /users?role=admin&sort=createdAt&order=desc
// GET /users?page=2&limit=50Error Handling and Validation
Consistent, informative error responses help developers debug issues quickly.
Standard Error Response Format
// Consistent error response structure
class ApiError extends Error {
constructor(statusCode, message, errors = []) {
super(message);
this.statusCode = statusCode;
this.errors = errors;
}
}
// Error response middleware
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
const response = {
error: {
message: err.message,
status: statusCode,
timestamp: new Date().toISOString(),
path: req.path
}
};
// Include validation errors if present
if (err.errors && err.errors.length > 0) {
response.error.details = err.errors;
}
// Include request ID for tracking
if (req.id) {
response.error.requestId = req.id;
}
// Log error for monitoring
console.error({
requestId: req.id,
error: err.message,
stack: err.stack,
statusCode
});
res.status(statusCode).json(response);
});
// Example error responses:
// 400 Bad Request
{
"error": {
"message": "Validation failed",
"status": 400,
"timestamp": "2024-12-04T10:30:00Z",
"path": "/users",
"requestId": "abc-123",
"details": [
{
"field": "email",
"message": "Email is required"
},
{
"field": "password",
"message": "Password must be at least 8 characters"
}
]
}
}
// 404 Not Found
{
"error": {
"message": "User not found",
"status": 404,
"timestamp": "2024-12-04T10:30:00Z",
"path": "/users/999",
"requestId": "xyz-456"
}
}Input Validation
// Using express-validator
const { body, validationResult } = require('express-validator');
router.post('/users',
[
body('email')
.isEmail()
.withMessage('Must be a valid email')
.normalizeEmail(),
body('password')
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters')
.matches(/\d/)
.withMessage('Password must contain a number'),
body('name')
.trim()
.isLength({ min: 1 })
.withMessage('Name is required'),
body('age')
.optional()
.isInt({ min: 18, max: 120 })
.withMessage('Age must be between 18 and 120')
],
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
error: {
message: 'Validation failed',
status: 400,
details: errors.array().map(err => ({
field: err.path,
message: err.msg
}))
}
});
}
const user = await User.create(req.body);
res.status(201).json(user);
}
);API Versioning Strategies
Versioning allows you to evolve your API without breaking existing clients.
1. URL Path Versioning (Recommended)
✅ Most common and visible approach:
/v1/users
/v2/users
/v1/organizations/123/members
Pros:
- Clear and explicit
- Easy to route and cache
- Simple to implement
Cons:
- URL duplication across versions// Express versioning implementation
const v1Router = express.Router();
const v2Router = express.Router();
// Version 1 - Original response format
v1Router.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json({
id: user.id,
name: user.name,
email: user.email
});
});
// Version 2 - Enhanced response format
v2Router.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json({
data: {
id: user.id,
type: 'user',
attributes: {
name: user.name,
email: user.email,
createdAt: user.createdAt,
updatedAt: user.updatedAt
},
links: {
self: `/v2/users/${user.id}`
}
}
});
});
app.use('/v1', v1Router);
app.use('/v2', v2Router);2. Header Versioning
// Accept header versioning
app.use('/users/:id', async (req, res) => {
const version = req.get('Accept-Version') || 'v1';
const user = await User.findById(req.params.id);
if (version === 'v2') {
return res.json({
data: {
id: user.id,
type: 'user',
attributes: { name: user.name, email: user.email }
}
});
}
// Default v1 response
res.json({ id: user.id, name: user.name, email: user.email });
});
// Request:
// GET /users/123
// Accept-Version: v2Pagination Best Practices
Implement pagination for all list endpoints to prevent performance issues:
Offset-Based Pagination
router.get('/users', async (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const offset = (page - 1) * limit;
const [users, total] = await Promise.all([
User.findAll({ limit, offset }),
User.count()
]);
res.json({
data: users,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
hasNext: page < Math.ceil(total / limit),
hasPrev: page > 1
},
links: {
self: `/users?page=${page}&limit=${limit}`,
first: `/users?page=1&limit=${limit}`,
last: `/users?page=${Math.ceil(total / limit)}&limit=${limit}`,
next: page < Math.ceil(total / limit)
? `/users?page=${page + 1}&limit=${limit}`
: null,
prev: page > 1
? `/users?page=${page - 1}&limit=${limit}`
: null
}
});
});Cursor-Based Pagination (for large datasets)
// Better for real-time data and large datasets
router.get('/posts', async (req, res) => {
const limit = parseInt(req.query.limit) || 20;
const cursor = req.query.cursor;
const query = { limit: limit + 1 }; // Fetch one extra to check hasNext
if (cursor) {
// Decode cursor (base64 encoded timestamp + id)
const decoded = Buffer.from(cursor, 'base64').toString();
const [timestamp, id] = decoded.split('|');
query.where = {
createdAt: { $lt: new Date(timestamp) }
};
}
const posts = await Post.findAll({
...query,
order: [['createdAt', 'DESC']]
});
const hasNext = posts.length > limit;
const data = hasNext ? posts.slice(0, limit) : posts;
let nextCursor = null;
if (hasNext) {
const last = data[data.length - 1];
const cursorData = `${last.createdAt.toISOString()}|${last.id}`;
nextCursor = Buffer.from(cursorData).toString('base64');
}
res.json({
data,
pagination: {
nextCursor,
hasNext
}
});
});Authentication and Security
1. Use HTTPS Everywhere
Always use HTTPS in production to encrypt data in transit. Redirect HTTP to HTTPS:
// Force HTTPS in production
app.use((req, res, next) => {
if (process.env.NODE_ENV === 'production' && !req.secure) {
return res.redirect(301, `https://${req.headers.host}${req.url}`);
}
next();
});2. Implement Rate Limiting
const rateLimit = require('express-rate-limit');
// General API rate limit
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: {
error: {
message: 'Too many requests, please try again later',
status: 429,
retryAfter: 900 // seconds
}
},
standardHeaders: true,
legacyHeaders: false
});
// Stricter limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // Only 5 attempts per window
skipSuccessfulRequests: true
});
app.use('/api/', apiLimiter);
app.use('/api/auth/', authLimiter);3. API Key Authentication
// API Key middleware
const authenticateApiKey = async (req, res, next) => {
const apiKey = req.get('X-API-Key');
if (!apiKey) {
return res.status(401).json({
error: {
message: 'API key required',
status: 401
}
});
}
const client = await ApiClient.findOne({ where: { apiKey } });
if (!client || !client.isActive) {
return res.status(401).json({
error: {
message: 'Invalid API key',
status: 401
}
});
}
// Attach client to request
req.client = client;
next();
};
app.use('/api/', authenticateApiKey);4. JWT Authentication
const jwt = require('jsonwebtoken');
const authenticateJWT = (req, res, next) => {
const authHeader = req.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: { message: 'Authentication required', status: 401 }
});
}
const token = authHeader.substring(7);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = payload;
next();
} catch (error) {
return res.status(401).json({
error: { message: 'Invalid or expired token', status: 401 }
});
}
};
app.use('/api/protected/', authenticateJWT);API Documentation
Comprehensive documentation is critical for API adoption. Use OpenAPI (Swagger) for interactive, standardized documentation:
# openapi.yaml
openapi: 3.0.0
info:
title: User Management API
version: 1.0.0
description: API for managing users
contact:
email: api@example.com
servers:
- url: https://api.example.com/v1
description: Production
- url: https://staging-api.example.com/v1
description: Staging
paths:
/users:
get:
summary: List all users
tags:
- Users
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: limit
in: query
schema:
type: integer
default: 20
responses:
'200':
description: Successful response
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/User'
pagination:
$ref: '#/components/schemas/Pagination'
post:
summary: Create a new user
tags:
- Users
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUserRequest'
responses:
'201':
description: User created
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'400':
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
User:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
email:
type: string
format: email
createdAt:
type: string
format: date-time
CreateUserRequest:
type: object
required:
- name
- email
- password
properties:
name:
type: string
email:
type: string
format: email
password:
type: string
minLength: 8
Error:
type: object
properties:
error:
type: object
properties:
message:
type: string
status:
type: integer
details:
type: array
items:
type: objectPerformance Optimization
- Use HTTP caching: Implement ETag, Cache-Control, and Last-Modified headers
- Compress responses: Enable gzip/brotli compression
- Implement field filtering: Allow clients to request specific fields
- Use database indexes: Optimize queries for common access patterns
- Implement response pagination: Never return unbounded result sets
- Consider GraphQL: For complex data requirements and over-fetching issues
// Field filtering (sparse fieldsets)
router.get('/users', async (req, res) => {
const fields = req.query.fields?.split(',') || ['id', 'name', 'email'];
const users = await User.findAll({
attributes: fields
});
res.json({ data: users });
});
// Request: GET /users?fields=id,name,email
// HTTP caching
router.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Set caching headers
res.set({
'Cache-Control': 'public, max-age=300', // Cache for 5 minutes
'ETag': `"${user.updatedAt.getTime()}"`,
'Last-Modified': user.updatedAt.toUTCString()
});
// Check If-None-Match header
if (req.get('If-None-Match') === `"${user.updatedAt.getTime()}"`) {
return res.status(304).send();
}
res.json(user);
});Conclusion
Building developer-friendly APIs requires attention to consistency, clarity, and developer experience. By following REST principles, implementing proper error handling, versioning strategically, securing your API, and providing comprehensive documentation, you create APIs that developers enjoy using and that stand the test of time.
Remember that API design is about empathy—putting yourself in the shoes of developers who will consume your API. Invest time in getting the design right upfront, as changing APIs after release is costly and can break client integrations.
API Design Checklist
✓ Use RESTful resource naming with nouns
✓ Implement proper HTTP status codes
✓ Provide consistent error responses
✓ Version your API from day one
✓ Implement pagination for list endpoints
✓ Use HTTPS everywhere
✓ Add rate limiting to prevent abuse
✓ Document with OpenAPI/Swagger
✓ Implement authentication (API keys or JWT)
✓ Enable CORS for web clients
✓ Add HTTP caching headers
✓ Validate all inputs
✓ Log requests with unique IDs
Next Steps
Ready to build robust, scalable APIs? At Jishu Labs, our engineering team specializes in API design and backend development. We can help you design APIs from scratch, refactor existing APIs for better performance and developer experience, or provide API security audits.
Contact us to discuss your API needs, or explore our Custom Software Development services.
About Sarah Johnson
Sarah Johnson is the CTO at Jishu Labs with 15+ years of experience in software architecture and API design. She has designed and scaled APIs serving billions of requests per day for enterprise and consumer applications. Sarah is passionate about developer experience and building APIs that are intuitive, secure, and performant.