Best Practices for API Design (That Work for Any Stack)
Introduction
API design is one of the most critical aspects of modern software development, yet it's often treated as an afterthought. Whether you're building a REST API with Node.js, a GraphQL endpoint, or any other API architecture, following proven design principles can make the difference between a frustrating developer experience and a delightful one.
This comprehensive guide covers universal API design best practices that work regardless of your technology stack. We'll explore practical patterns, common pitfalls, and real-world examples that will help you build APIs that are maintainable, scalable, and developer-friendly.
Good API design isn't just about following conventions—it's about creating interfaces that are intuitive, consistent, and built to evolve with your application over time.
Core API Design Principles
1. Consistency is King
Consistency in API design reduces cognitive load and makes your API predictable for developers. This applies to naming conventions, response formats, error handling, and URL structures.
Naming Conventions
// ✅ Good - Consistent naming
GET / api / users;
GET / api / users / 123;
POST / api / users;
PUT / api / users / 123;
DELETE / api / users / 123;
GET / api / orders;
GET / api / orders / 456;
POST / api / orders;
// ❌ Bad - Inconsistent naming
GET / api / users;
GET / api / user / 123;
POST / api / createUser;
PUT / api / updateUser / 123;
DELETE / api / removeUser / 123;
GET / api / order - list;
GET / api / getOrder / 456;
POST / api / create_order;
Response Format Consistency
// ✅ Good - Consistent response format
{
"success": true,
"data": {
"id": 123,
"name": "John Doe",
"email": "john@example.com"
},
"meta": {
"timestamp": "2025-09-11T10:30:00Z",
"version": "1.0"
}
}
// For collections
{
"success": true,
"data": [
{ "id": 1, "name": "John" },
{ "id": 2, "name": "Jane" }
],
"meta": {
"total": 150,
"page": 1,
"perPage": 20,
"totalPages": 8
}
}
// ❌ Bad - Inconsistent formats
// Sometimes returns array directly
[
{ "id": 1, "name": "John" },
{ "id": 2, "name": "Jane" }
]
// Sometimes wrapped differently
{
"result": { "id": 123, "name": "John" },
"status": "ok"
}
2. RESTful Resource Design
Design your API around resources, not actions. Resources should be nouns, and HTTP methods should represent the actions.
Resource-Oriented URLs
// ✅ Good - Resource-oriented design
GET / api / users; // Get all users
GET / api / users / 123; // Get specific user
POST / api / users; // Create new user
PUT / api / users / 123; // Update entire user
PATCH / api / users / 123; // Partial update user
DELETE / api / users / 123; // Delete user
// Nested resources
GET / api / users / 123 / orders; // Get user's orders
POST / api / users / 123 / orders; // Create order for user
GET / api / orders / 456 / items; // Get order items
// ❌ Bad - Action-oriented design
GET / api / getAllUsers;
POST / api / createUser;
POST / api / updateUser;
POST / api / deleteUser / 123;
GET / api / getUserOrders / 123;
HTTP Status Codes
Use appropriate HTTP status codes to communicate the result of operations:
// Success responses
200 OK // Successful GET, PUT, PATCH
201 Created // Successful POST
202 Accepted // Request accepted for processing
204 No Content // Successful DELETE or empty response
// Client error responses
400 Bad Request // Invalid request data
401 Unauthorized // Authentication required
403 Forbidden // Access denied
404 Not Found // Resource doesn't exist
409 Conflict // Resource conflict
422 Unprocessable Entity // Validation errors
429 Too Many Requests // Rate limiting
// Server error responses
500 Internal Server Error // Server error
502 Bad Gateway // Upstream server error
503 Service Unavailable // Server temporarily down
Error Handling Best Practices
Structured Error Responses
Provide consistent, informative error responses that help developers understand and fix issues:
// ✅ Good - Structured error response
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "The request data is invalid",
"details": [
{
"field": "email",
"message": "Email must be a valid email address",
"code": "INVALID_EMAIL"
},
{
"field": "password",
"message": "Password must be at least 8 characters long",
"code": "PASSWORD_TOO_SHORT"
}
]
},
"meta": {
"timestamp": "2025-09-11T10:30:00Z",
"requestId": "req_123456789"
}
}
// ❌ Bad - Vague error response
{
"error": "Something went wrong"
}
Error Response Implementation
// Node.js/Express example
class APIError extends Error {
constructor(message, statusCode, code, details = []) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.details = details;
}
}
// Error handler middleware
const errorHandler = (err, req, res, next) => {
const response = {
success: false,
error: {
code: err.code || "INTERNAL_ERROR",
message: err.message || "An unexpected error occurred",
...(err.details && { details: err.details }),
},
meta: {
timestamp: new Date().toISOString(),
requestId: req.id || generateRequestId(),
},
};
// Don't expose internal errors in production
if (process.env.NODE_ENV === "production" && err.statusCode >= 500) {
response.error.message = "Internal server error";
delete response.error.details;
}
res.status(err.statusCode || 500).json(response);
};
// Usage in controllers
const createUser = async (req, res, next) => {
try {
const { email, password } = req.body;
// Validation
const errors = [];
if (!isValidEmail(email)) {
errors.push({
field: "email",
message: "Email must be a valid email address",
code: "INVALID_EMAIL",
});
}
if (password.length < 8) {
errors.push({
field: "password",
message: "Password must be at least 8 characters long",
code: "PASSWORD_TOO_SHORT",
});
}
if (errors.length > 0) {
throw new APIError(
"The request data is invalid",
422,
"VALIDATION_ERROR",
errors
);
}
const user = await User.create({ email, password });
res.status(201).json({
success: true,
data: user,
});
} catch (error) {
next(error);
}
};
Pagination and Filtering
Pagination Strategies
Implement pagination to handle large datasets efficiently:
// ✅ Good - Offset-based pagination
GET /api/users?page=1&limit=20
// Response
{
"success": true,
"data": [...],
"meta": {
"pagination": {
"page": 1,
"limit": 20,
"total": 500,
"totalPages": 25,
"hasNext": true,
"hasPrev": false
}
}
}
// ✅ Better - Cursor-based pagination (for real-time data)
GET /api/posts?cursor=eyJpZCI6MTIzfQ&limit=20
// Response
{
"success": true,
"data": [...],
"meta": {
"pagination": {
"limit": 20,
"nextCursor": "eyJpZCI6MTQ1fQ",
"prevCursor": "eyJpZCI6MTAwfQ",
"hasNext": true,
"hasPrev": true
}
}
}
Filtering and Sorting
// Query parameters for filtering and sorting
GET /api/users?status=active&role=admin&sort=createdAt:desc&search=john
// Implementation example
const getUsers = async (req, res) => {
const {
page = 1,
limit = 20,
status,
role,
sort,
search
} = req.query;
const filters = {};
if (status) filters.status = status;
if (role) filters.role = role;
let query = User.find(filters);
// Search functionality
if (search) {
query = query.where({
$or: [
{ name: new RegExp(search, 'i') },
{ email: new RegExp(search, 'i') }
]
});
}
// Sorting
if (sort) {
const [field, order] = sort.split(':');
query = query.sort({ [field]: order === 'desc' ? -1 : 1 });
}
const total = await User.countDocuments(query.getFilter());
const users = await query
.skip((page - 1) * limit)
.limit(parseInt(limit))
.exec();
res.json({
success: true,
data: users,
meta: {
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
totalPages: Math.ceil(total / limit),
hasNext: page * limit < total,
hasPrev: page > 1
}
}
});
};
Versioning Strategies
URL Path Versioning
// ✅ Good - URL path versioning
GET / api / v1 / users;
GET / api / v2 / users;
// Express.js implementation
const express = require("express");
const app = express();
// Version 1 routes
app.use("/api/v1", require("./routes/v1"));
// Version 2 routes
app.use("/api/v2", require("./routes/v2"));
// Default to latest version
app.use("/api", require("./routes/v2"));
Header-based Versioning
// Alternative: Header-based versioning
GET /api/users
Headers: {
"API-Version": "1.0",
"Accept": "application/vnd.api+json;version=1"
}
// Express.js middleware for header versioning
const versionMiddleware = (req, res, next) => {
const version = req.headers['api-version'] || '1.0';
req.apiVersion = version;
next();
};
// Route handler
app.get('/api/users', versionMiddleware, (req, res) => {
if (req.apiVersion === '2.0') {
// Handle v2.0 logic
return res.json({ data: usersV2, version: '2.0' });
}
// Default to v1.0
res.json({ data: usersV1, version: '1.0' });
});
Authentication and Security
JWT Authentication Implementation
// JWT middleware
const jwt = require("jsonwebtoken");
const authenticateToken = (req, res, next) => {
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({
success: false,
error: {
code: "NO_TOKEN",
message: "Access token is required",
},
});
}
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({
success: false,
error: {
code: "INVALID_TOKEN",
message: "Invalid or expired access token",
},
});
}
req.user = user;
next();
});
};
// Usage
app.get("/api/users/profile", authenticateToken, (req, res) => {
res.json({
success: true,
data: req.user,
});
});
API Key Authentication
// API key middleware
const apiKeyAuth = (req, res, next) => {
const apiKey = req.headers["x-api-key"];
if (!apiKey) {
return res.status(401).json({
success: false,
error: {
code: "NO_API_KEY",
message: "API key is required",
},
});
}
// Validate API key (check against database or cache)
if (!isValidApiKey(apiKey)) {
return res.status(403).json({
success: false,
error: {
code: "INVALID_API_KEY",
message: "Invalid API key",
},
});
}
next();
};
// Rate limiting per API key
const rateLimit = require("express-rate-limit");
const rateLimitByApiKey = rateLimit({
keyGenerator: (req) => req.headers["x-api-key"],
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each API key to 100 requests per windowMs
message: {
success: false,
error: {
code: "RATE_LIMIT_EXCEEDED",
message: "Too many requests, please try again later",
},
},
});
Performance Optimization
Caching Strategies
// Redis caching middleware
const redis = require("redis");
const client = redis.createClient();
const cacheMiddleware = (duration = 300) => {
return async (req, res, next) => {
const key = `cache:${req.originalUrl}`;
try {
const cached = await client.get(key);
if (cached) {
return res.json(JSON.parse(cached));
}
// Override res.json to cache response
const originalJson = res.json;
res.json = function (body) {
// Cache successful responses
if (res.statusCode === 200) {
client.setex(key, duration, JSON.stringify(body));
}
originalJson.call(this, body);
};
next();
} catch (error) {
console.error("Cache error:", error);
next();
}
};
};
// Usage
app.get("/api/users", cacheMiddleware(600), (req, res) => {
// This response will be cached for 10 minutes
res.json({
success: true,
data: users,
});
});
Database Query Optimization
// Efficient data fetching with select fields
const getUsers = async (req, res) => {
const { fields } = req.query;
let query = User.find();
// Only select requested fields to reduce payload
if (fields) {
const selectedFields = fields.split(",").join(" ");
query = query.select(selectedFields);
} else {
// Default fields (exclude sensitive data)
query = query.select("-password -resetToken");
}
const users = await query.exec();
res.json({
success: true,
data: users,
});
};
// Usage: GET /api/users?fields=name,email,createdAt
Response Compression
// Enable gzip compression
const compression = require("compression");
app.use(compression());
// Custom compression for specific routes
const shouldCompress = (req, res) => {
if (req.headers["x-no-compression"]) {
return false;
}
return compression.filter(req, res);
};
app.use(compression({ filter: shouldCompress }));
Documentation and Testing
OpenAPI/Swagger Documentation
// swagger.js - API documentation setup
const swaggerJsdoc = require("swagger-jsdoc");
const swaggerUi = require("swagger-ui-express");
const options = {
definition: {
openapi: "3.0.0",
info: {
title: "My API",
version: "1.0.0",
description: "A sample API with best practices",
},
servers: [
{
url: "http://localhost:3000/api",
description: "Development server",
},
{
url: "https://api.example.com",
description: "Production server",
},
],
},
apis: ["./routes/*.js"], // paths to files containing OpenAPI definitions
};
const specs = swaggerJsdoc(options);
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(specs));
// Example route documentation
/**
* @swagger
* /users:
* get:
* summary: Retrieve a list of users
* tags: [Users]
* parameters:
* - in: query
* name: page
* schema:
* type: integer
* description: Page number
* - in: query
* name: limit
* schema:
* type: integer
* description: Number of users per page
* responses:
* 200:
* description: A list of users
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* data:
* type: array
* items:
* $ref: '#/components/schemas/User'
* 401:
* description: Unauthorized
*/
API Testing Best Practices
// Jest + Supertest example
const request = require("supertest");
const app = require("../app");
describe("Users API", () => {
describe("GET /api/users", () => {
it("should return users with correct format", async () => {
const response = await request(app).get("/api/users").expect(200);
expect(response.body).toHaveProperty("success", true);
expect(response.body).toHaveProperty("data");
expect(response.body).toHaveProperty("meta");
expect(Array.isArray(response.body.data)).toBe(true);
});
it("should support pagination", async () => {
const response = await request(app)
.get("/api/users?page=1&limit=5")
.expect(200);
expect(response.body.data.length).toBeLessThanOrEqual(5);
expect(response.body.meta.pagination).toBeDefined();
});
it("should handle invalid page parameter", async () => {
const response = await request(app)
.get("/api/users?page=invalid")
.expect(400);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe("INVALID_PARAMETER");
});
});
describe("POST /api/users", () => {
it("should create user with valid data", async () => {
const userData = {
name: "John Doe",
email: "john@example.com",
password: "password123",
};
const response = await request(app)
.post("/api/users")
.send(userData)
.expect(201);
expect(response.body.success).toBe(true);
expect(response.body.data.email).toBe(userData.email);
expect(response.body.data.password).toBeUndefined(); // Should not return password
});
it("should validate required fields", async () => {
const response = await request(app)
.post("/api/users")
.send({})
.expect(422);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe("VALIDATION_ERROR");
expect(response.body.error.details).toBeDefined();
});
});
});
GraphQL Best Practices
Schema Design
# schema.graphql
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: DateTime!
updatedAt: DateTime!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
published: Boolean!
tags: [String!]!
createdAt: DateTime!
}
type Query {
users(
first: Int = 10
after: String
filter: UserFilter
sort: UserSort
): UserConnection!
user(id: ID!): User
posts(first: Int = 10, after: String, filter: PostFilter): PostConnection!
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
deleteUser(id: ID!): DeleteUserPayload!
}
input CreateUserInput {
name: String!
email: String!
password: String!
}
input UpdateUserInput {
name: String
email: String
}
type CreateUserPayload {
user: User
errors: [Error!]!
}
type Error {
field: String
message: String!
code: String!
}
Resolver Implementation
// resolvers.js
const resolvers = {
Query: {
users: async (_, { first, after, filter, sort }, context) => {
// Implement pagination, filtering, and sorting
const users = await context.dataSources.userAPI.getUsers({
first,
after,
filter,
sort,
});
return users;
},
user: async (_, { id }, context) => {
return await context.dataSources.userAPI.getUserById(id);
},
},
Mutation: {
createUser: async (_, { input }, context) => {
try {
const user = await context.dataSources.userAPI.createUser(input);
return {
user,
errors: [],
};
} catch (error) {
return {
user: null,
errors: [
{
field: error.field,
message: error.message,
code: error.code,
},
],
};
}
},
},
User: {
posts: async (user, _, context) => {
// Use DataLoader to prevent N+1 queries
return context.dataSources.postAPI.getPostsByUserId(user.id);
},
},
};
// DataLoader for efficient batching
const DataLoader = require("dataloader");
const createPostLoader = (postAPI) => {
return new DataLoader(async (userIds) => {
const posts = await postAPI.getPostsByUserIds(userIds);
return userIds.map((userId) =>
posts.filter((post) => post.authorId === userId)
);
});
};
Monitoring and Logging
Request Logging
// Morgan logging middleware with custom format
const morgan = require("morgan");
// Custom token for request ID
morgan.token("id", (req) => req.id);
// Custom format
const logFormat =
":id :method :url :status :response-time ms - :res[content-length]";
app.use(morgan(logFormat));
// Structured logging with Winston
const winston = require("winston");
const logger = winston.createLogger({
level: "info",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: "logs/error.log", level: "error" }),
new winston.transports.File({ filename: "logs/combined.log" }),
],
});
// API request logging middleware
const apiLogger = (req, res, next) => {
const startTime = Date.now();
res.on("finish", () => {
const duration = Date.now() - startTime;
logger.info("API Request", {
requestId: req.id,
method: req.method,
url: req.url,
statusCode: res.statusCode,
duration,
userAgent: req.get("User-Agent"),
ip: req.ip,
userId: req.user?.id,
});
});
next();
};
Health Checks
// Health check endpoint
app.get("/api/health", async (req, res) => {
const health = {
status: "healthy",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
version: process.env.npm_package_version,
checks: {},
};
try {
// Database check
await mongoose.connection.db.admin().ping();
health.checks.database = { status: "healthy" };
} catch (error) {
health.checks.database = {
status: "unhealthy",
error: error.message,
};
health.status = "unhealthy";
}
try {
// Redis check
await redisClient.ping();
health.checks.redis = { status: "healthy" };
} catch (error) {
health.checks.redis = {
status: "unhealthy",
error: error.message,
};
}
const statusCode = health.status === "healthy" ? 200 : 503;
res.status(statusCode).json(health);
});
Real-world Implementation Example
Here's a complete example putting together all the best practices:
// app.js - Complete API implementation
const express = require("express");
const helmet = require("helmet");
const cors = require("cors");
const compression = require("compression");
const rateLimit = require("express-rate-limit");
const { v4: uuidv4 } = require("uuid");
const app = express();
// Security middleware
app.use(helmet());
app.use(cors());
app.use(compression());
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
message: {
success: false,
error: {
code: "RATE_LIMIT_EXCEEDED",
message: "Too many requests, please try again later",
},
},
});
app.use("/api", limiter);
// Request parsing
app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true }));
// Request ID middleware
app.use((req, res, next) => {
req.id = uuidv4();
res.setHeader("X-Request-ID", req.id);
next();
});
// API routes
app.use("/api/v1", require("./routes/v1"));
app.use("/api/v2", require("./routes/v2"));
app.use("/api", require("./routes/v2")); // Default to latest
// Health check
app.get("/health", require("./middleware/healthCheck"));
// Global error handler
app.use((err, req, res, next) => {
const response = {
success: false,
error: {
code: err.code || "INTERNAL_ERROR",
message: err.message || "An unexpected error occurred",
},
meta: {
timestamp: new Date().toISOString(),
requestId: req.id,
},
};
if (process.env.NODE_ENV !== "production") {
response.error.stack = err.stack;
}
res.status(err.statusCode || 500).json(response);
});
// 404 handler
app.use((req, res) => {
res.status(404).json({
success: false,
error: {
code: "NOT_FOUND",
message: "The requested resource was not found",
},
meta: {
timestamp: new Date().toISOString(),
requestId: req.id,
},
});
});
module.exports = app;
Conclusion
Building great APIs is both an art and a science. The principles and patterns covered in this guide form the foundation of APIs that are not just functional, but delightful to work with.
Remember these key takeaways:
Consistency First: Establish patterns and stick to them across your entire API surface.
Developer Experience: Design for the developers who will consume your API, not just the requirements.
Error Handling: Provide clear, actionable error messages that help developers fix issues quickly.
Performance: Implement caching, pagination, and efficient queries from the start.
Security: Build security into every layer, from authentication to input validation.
Documentation: Keep your API documentation current and comprehensive.
Monitoring: Implement logging and health checks to understand how your API is performing.
The best APIs are those that evolve thoughtfully over time while maintaining backward compatibility and clear migration paths. By following these practices, you'll build APIs that scale with your application and provide a solid foundation for your entire system.
Whether you're building a simple REST API or a complex GraphQL schema, these principles will serve you well across any technology stack and help you create APIs that developers love to use.
What challenges have you faced when designing APIs? Share your experiences and let's learn from each other's journey in building better APIs.