Why REST API Design Matters
A well-designed REST API is intuitive, consistent, and easy to integrate. Poor API design leads to confused developers, increased support costs, breaking changes, and ultimately slower adoption. Whether you are building a public API, a microservices backend, or an internal service, following established design conventions saves time and reduces friction. This guide covers the essential best practices for designing REST APIs in 2026 with practical examples you can apply immediately. For related content, see our REST API Best Practices guide.
Resource Naming Conventions
Resources are the fundamental concept in REST. They represent the entities in your system. Resource URIs should be nouns (not verbs), use plural forms, and follow a consistent naming scheme. Use the Slug Generator to create clean URL segments.
# Good: Noun-based, plural, hierarchical
GET /api/v1/users
GET /api/v1/users/123
GET /api/v1/users/123/orders
GET /api/v1/users/123/orders/456
POST /api/v1/users
PUT /api/v1/users/123
PATCH /api/v1/users/123
DELETE /api/v1/users/123
# Bad: Verb-based, inconsistent
GET /api/v1/getUsers
POST /api/v1/createUser
GET /api/v1/user/123/getOrders
POST /api/v1/deleteUser/123
# Resource naming rules:
# 1. Use plural nouns: /users not /user
# 2. Use kebab-case: /order-items not /orderItems
# 3. Use lowercase: /users not /Users
# 4. Nest for relationships: /users/123/posts
# 5. Max 3 levels of nesting: /users/123/posts/456HTTP Methods and Their Semantics
Each HTTP method has specific semantics. Using them correctly makes your API predictable and self-documenting. Check our HTTP Status Codes reference for response codes to pair with each method.
| Method | Action | Idempotent | Request Body | Success Code |
|---|---|---|---|---|
| GET | Read resource(s) | Yes | No | 200 OK |
| POST | Create resource | No | Yes | 201 Created |
| PUT | Full replace | Yes | Yes | 200 OK |
| PATCH | Partial update | No* | Yes | 200 OK |
| DELETE | Remove resource | Yes | No | 204 No Content |
| HEAD | Get headers only | Yes | No | 200 OK |
| OPTIONS | List allowed methods | Yes | No | 204 No Content |
Request and Response Design
Request Body Structure
// POST /api/v1/users — Create a new user
// Request:
{
"name": "Alice Johnson",
"email": "alice@example.com",
"role": "admin",
"preferences": {
"language": "en",
"timezone": "America/New_York",
"notifications": {
"email": true,
"sms": false
}
}
}
// Response (201 Created):
{
"data": {
"id": "usr_abc123",
"name": "Alice Johnson",
"email": "alice@example.com",
"role": "admin",
"preferences": {
"language": "en",
"timezone": "America/New_York",
"notifications": {
"email": true,
"sms": false
}
},
"createdAt": "2026-02-22T10:30:00Z",
"updatedAt": "2026-02-22T10:30:00Z"
}
}Consistent Response Envelope
// Standard response format for all endpoints
interface ApiResponse<T> {
data: T;
meta?: {
page: number;
perPage: number;
total: number;
totalPages: number;
};
links?: {
self: string;
first?: string;
prev?: string;
next?: string;
last?: string;
};
}
interface ErrorResponse {
error: {
code: string;
message: string;
details?: Array<{
field: string;
message: string;
code: string;
}>;
requestId: string;
documentation?: string;
};
}
// GET /api/v1/users?page=2&per_page=20
// Response (200 OK):
{
"data": [
{ "id": "usr_abc123", "name": "Alice", "email": "alice@example.com" },
{ "id": "usr_def456", "name": "Bob", "email": "bob@example.com" }
],
"meta": {
"page": 2,
"perPage": 20,
"total": 145,
"totalPages": 8
},
"links": {
"self": "/api/v1/users?page=2&per_page=20",
"first": "/api/v1/users?page=1&per_page=20",
"prev": "/api/v1/users?page=1&per_page=20",
"next": "/api/v1/users?page=3&per_page=20",
"last": "/api/v1/users?page=8&per_page=20"
}
}Error Handling
Consistent error responses are critical for developer experience. Always include an error code, human-readable message, and enough detail for the client to fix the problem.
// 400 Bad Request — Validation error
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{
"field": "email",
"message": "Must be a valid email address",
"code": "INVALID_FORMAT"
},
{
"field": "age",
"message": "Must be between 18 and 120",
"code": "OUT_OF_RANGE"
}
],
"requestId": "req_xyz789"
}
}
// 401 Unauthorized
{
"error": {
"code": "UNAUTHORIZED",
"message": "Invalid or expired authentication token",
"documentation": "https://api.example.com/docs/authentication",
"requestId": "req_xyz790"
}
}
// 404 Not Found
{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "User with ID 'usr_invalid' not found",
"requestId": "req_xyz791"
}
}
// 429 Too Many Requests
{
"error": {
"code": "RATE_LIMITED",
"message": "Rate limit exceeded. Try again in 30 seconds.",
"requestId": "req_xyz792"
}
}
// 500 Internal Server Error
{
"error": {
"code": "INTERNAL_ERROR",
"message": "An unexpected error occurred",
"requestId": "req_xyz793"
}
}Pagination
Always paginate list endpoints. Support multiple pagination strategies based on your use case.
# Offset-based pagination (simple, good for small datasets)
GET /api/v1/users?page=3&per_page=20
# Cursor-based pagination (better for large/real-time datasets)
GET /api/v1/users?after=usr_abc123&limit=20
# Keyset pagination (efficient for sorted data)
GET /api/v1/users?created_after=2026-01-01&limit=20// Express.js pagination implementation
app.get('/api/v1/users', async (req, res) => {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const perPage = Math.min(100, Math.max(1,
parseInt(req.query.per_page as string) || 20
));
const offset = (page - 1) * perPage;
const [users, total] = await Promise.all([
db.users.findMany({ skip: offset, take: perPage }),
db.users.count(),
]);
const totalPages = Math.ceil(total / perPage);
const baseUrl = '/api/v1/users';
res.json({
data: users,
meta: { page, perPage, total, totalPages },
links: {
self: `${baseUrl}?page=${page}&per_page=${perPage}`,
first: `${baseUrl}?page=1&per_page=${perPage}`,
...(page > 1 && {
prev: `${baseUrl}?page=${page - 1}&per_page=${perPage}`
}),
...(page < totalPages && {
next: `${baseUrl}?page=${page + 1}&per_page=${perPage}`
}),
last: `${baseUrl}?page=${totalPages}&per_page=${perPage}`,
},
});
});Filtering, Sorting, and Field Selection
# Filtering — use query parameters
GET /api/v1/users?role=admin&status=active
GET /api/v1/orders?created_after=2026-01-01&total_gte=100
GET /api/v1/products?category=electronics&price_lte=500
# Sorting — use sort parameter with +/- prefix
GET /api/v1/users?sort=-created_at # Descending
GET /api/v1/users?sort=name # Ascending
GET /api/v1/users?sort=-created_at,+name # Multiple
# Field selection — reduce response payload
GET /api/v1/users?fields=id,name,email
GET /api/v1/users/123?fields=name,avatar,posts.title
# Search — full-text search parameter
GET /api/v1/users?q=alice+johnson
GET /api/v1/products?search=wireless+keyboardVersioning
API versioning protects existing clients from breaking changes. There are three common approaches.
# 1. URL path versioning (most common, recommended)
GET /api/v1/users
GET /api/v2/users
# 2. Header versioning
GET /api/users
Accept: application/vnd.myapi.v2+json
# 3. Query parameter versioning
GET /api/users?version=2// Express.js versioned routes
import express from 'express';
import v1Router from './routes/v1';
import v2Router from './routes/v2';
const app = express();
// Mount versioned routes
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// v1/routes.ts
const router = express.Router();
router.get('/users', getUsersV1);
router.get('/users/:id', getUserV1);
// v2/routes.ts — same endpoints, new response format
const router = express.Router();
router.get('/users', getUsersV2);
router.get('/users/:id', getUserV2);Authentication and Authorization
Use standard authentication mechanisms. JWT Bearer tokens are the most common choice for stateless APIs. For detailed coverage, see our API Authentication Guide and JWT Authentication Guide.
// Authentication middleware
import jwt from 'jsonwebtoken';
interface AuthRequest extends Request {
user?: { id: string; role: string; email: string };
}
function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({
error: {
code: 'MISSING_TOKEN',
message: 'Authorization header with Bearer token required',
},
});
}
const token = authHeader.slice(7);
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as any;
req.user = { id: decoded.sub, role: decoded.role, email: decoded.email };
next();
} catch (error) {
return res.status(401).json({
error: {
code: 'INVALID_TOKEN',
message: 'Token is invalid or expired',
},
});
}
}
// Role-based authorization
function authorize(...roles: string[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({
error: {
code: 'FORBIDDEN',
message: 'Insufficient permissions for this action',
},
});
}
next();
};
}
// Apply to routes
app.get('/api/v1/users', authenticate, authorize('admin'), getUsers);
app.get('/api/v1/profile', authenticate, getProfile);Rate Limiting
// Rate limiting with standard headers
import rateLimit from 'express-rate-limit';
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true, // RateLimit-* headers
legacyHeaders: false,
message: {
error: {
code: 'RATE_LIMITED',
message: 'Too many requests. Please try again later.',
},
},
});
app.use('/api/', apiLimiter);
// Response headers:
// RateLimit-Limit: 100
// RateLimit-Remaining: 87
// RateLimit-Reset: 1708646400HATEOAS: Hypermedia Links
HATEOAS (Hypermedia As The Engine Of Application State) makes your API self-discoverable by including links to related resources and available actions in responses.
// GET /api/v1/orders/ord_123
{
"data": {
"id": "ord_123",
"status": "pending",
"total": 99.99,
"items": [
{ "productId": "prod_456", "quantity": 2, "price": 49.99 }
]
},
"links": {
"self": "/api/v1/orders/ord_123",
"customer": "/api/v1/users/usr_789",
"items": "/api/v1/orders/ord_123/items",
"cancel": "/api/v1/orders/ord_123/cancel",
"pay": "/api/v1/orders/ord_123/pay"
},
"actions": {
"cancel": {
"method": "POST",
"href": "/api/v1/orders/ord_123/cancel"
},
"pay": {
"method": "POST",
"href": "/api/v1/orders/ord_123/pay",
"fields": [
{ "name": "paymentMethod", "type": "string", "required": true }
]
}
}
}Caching
// Implement HTTP caching headers
app.get('/api/v1/products/:id', async (req, res) => {
const product = await db.products.findById(req.params.id);
// ETag for conditional requests
const etag = generateETag(product);
res.set('ETag', etag);
// If client has current version, return 304
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
// Cache-Control for CDN and browser caching
res.set('Cache-Control', 'public, max-age=300, s-maxage=600');
// Last-Modified for time-based caching
res.set('Last-Modified', product.updatedAt.toUTCString());
res.json({ data: product });
});
// Vary header for content negotiation
app.get('/api/v1/articles', (req, res) => {
res.set('Vary', 'Accept-Language, Accept');
// ...
});OpenAPI Documentation
# openapi.yaml — Document your API
openapi: 3.1.0
info:
title: My REST API
version: 1.0.0
description: Production API for managing users and orders
servers:
- url: https://api.example.com/v1
paths:
/users:
get:
summary: List all users
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: per_page
in: query
schema:
type: integer
default: 20
maximum: 100
responses:
'200':
description: Paginated user list
content:
application/json:
schema:
$ref: '#/components/schemas/UserListResponse'
'401':
$ref: '#/components/responses/Unauthorized'
components:
schemas:
User:
type: object
required: [id, name, email]
properties:
id:
type: string
example: usr_abc123
name:
type: string
example: Alice Johnson
email:
type: string
format: emailREST API Design Checklist
| Category | Practice | Status |
|---|---|---|
| URLs | Use plural nouns for resources | Required |
| URLs | Use kebab-case, lowercase | Required |
| Methods | Use correct HTTP methods | Required |
| Status Codes | Return appropriate status codes | Required |
| Responses | Consistent response envelope | Required |
| Errors | Structured error responses | Required |
| Pagination | Paginate all list endpoints | Required |
| Versioning | Version your API from day one | Required |
| Auth | JWT or OAuth2 authentication | Required |
| Rate Limiting | Protect against abuse | Required |
| Caching | ETag and Cache-Control headers | Recommended |
| HATEOAS | Include hypermedia links | Recommended |
| Filtering | Query parameter filtering | Recommended |
| Docs | OpenAPI specification | Required |
| CORS | Configure CORS headers | Required |
Designing a great REST API is about consistency, predictability, and developer experience. Follow these conventions from the start, document everything with OpenAPI, and your API consumers will thank you. For more API resources, explore our CORS Errors Guide, HTTP Status Codes Guide, and JSON Formatter for testing API responses.