REST Principles: Building APIs That Developers Love
REST (Representational State Transfer) is an architectural style for designing networked applications. A well-designed REST API is intuitive, consistent, and a joy to consume. In 2025, with the API economy booming, getting your API design right is more important than ever.
Core REST Principles
- Resources: Everything is a resource identified by a URI (e.g.,
/users/42)
- HTTP Methods: Use standard verbs (GET, POST, PUT, PATCH, DELETE) to perform operations
- Statelessness: Each request contains all the information needed to process it — no server-side sessions
- Uniform Interface: Consistent URL patterns, response formats, and error handling
- HATEOAS: Responses include links to related resources and available actions
URI Design Conventions
# GOOD — nouns, plural, lowercase, hyphens
GET /api/v1/users # List users
GET /api/v1/users/42 # Get user 42
POST /api/v1/users # Create user
PUT /api/v1/users/42 # Replace user 42
PATCH /api/v1/users/42 # Partially update user 42
DELETE /api/v1/users/42 # Delete user 42
# Nested resources
GET /api/v1/users/42/orders # User 42's orders
GET /api/v1/users/42/orders/7 # User 42's order 7
# Filtering, sorting, pagination via query params
GET /api/v1/products?category=electronics&sort=-price&page=2&limit=20
# BAD — verbs in URL, inconsistent naming
GET /api/getUsers # Don't use verbs
GET /api/v1/User/42 # Don't use singular or PascalCase
POST /api/v1/users/create # POST already means create
DELETE /api/v1/users/42/delete # DELETE already means delete
HTTP Status Codes: Complete Reference
| Code | Name | When to Use |
| 200 | OK | Successful GET, PUT, PATCH, or DELETE |
| 201 | Created | Successful POST that created a resource |
| 204 | No Content | Successful DELETE with no response body |
| 301 | Moved Permanently | Resource URL has permanently changed |
| 304 | Not Modified | Cached version is still valid (ETag/If-Modified) |
| 400 | Bad Request | Invalid request body or parameters |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Authenticated but insufficient permissions |
| 404 | Not Found | Resource does not exist |
| 405 | Method Not Allowed | HTTP method not supported for this endpoint |
| 409 | Conflict | Resource conflict (e.g., duplicate email) |
| 422 | Unprocessable Entity | Validation errors in request body |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unexpected server error |
| 502 | Bad Gateway | Upstream server error |
| 503 | Service Unavailable | Server is temporarily down (maintenance) |
Request and Response Format Standards
// Successful response (single resource)
{
"data": {
"id": 42,
"type": "user",
"attributes": {
"name": "Alice Johnson",
"email": "alice@example.com",
"created_at": "2025-01-15T10:30:00Z"
}
}
}
// Successful response (collection)
{
"data": [
{ "id": 1, "name": "Alice" },
{ "id": 2, "name": "Bob" }
],
"meta": {
"total": 150,
"page": 1,
"per_page": 20,
"total_pages": 8
},
"links": {
"self": "/api/v1/users?page=1",
"next": "/api/v1/users?page=2",
"last": "/api/v1/users?page=8"
}
}
Pagination: Cursor vs Offset
Offset-Based Pagination
// Express implementation
app.get("/api/v1/products", async (req: Request, res: Response) => {
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const offset = (page - 1) * limit;
const [products, total] = await Promise.all([
db.query("SELECT * FROM products ORDER BY id LIMIT ? OFFSET ?", [limit, offset]),
db.query("SELECT COUNT(*) as total FROM products"),
]);
res.json({
data: products,
meta: { total: total[0].total, page, per_page: limit, total_pages: Math.ceil(total[0].total / limit) },
links: {
self: `/api/v1/products?page=${page}&limit=${limit}`,
next: page * limit < total[0].total ? `/api/v1/products?page=${page + 1}&limit=${limit}` : null,
prev: page > 1 ? `/api/v1/products?page=${page - 1}&limit=${limit}` : null,
},
});
});
Cursor-Based Pagination (Recommended for Large Datasets)
app.get("/api/v1/products", async (req: Request, res: Response) => {
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const cursor = req.query.cursor as string; // Base64-encoded ID
let query = "SELECT * FROM products";
const params: any[] = [];
if (cursor) {
const decodedCursor = Buffer.from(cursor, "base64").toString("utf-8");
query += " WHERE id > ?";
params.push(parseInt(decodedCursor));
}
query += " ORDER BY id ASC LIMIT ?";
params.push(limit + 1); // Fetch one extra to check if there's a next page
const products = await db.query(query, params);
const hasMore = products.length > limit;
if (hasMore) products.pop(); // Remove the extra item
const nextCursor = hasMore
? Buffer.from(String(products[products.length - 1].id)).toString("base64")
: null;
res.json({
data: products,
meta: { has_more: hasMore },
links: {
next: nextCursor ? `/api/v1/products?cursor=${nextCursor}&limit=${limit}` : null,
},
});
});
Filtering and Sorting
// GET /api/v1/products?category=electronics&min_price=50&max_price=500&sort=-price,name
app.get("/api/v1/products", async (req: Request, res: Response) => {
let query = "SELECT * FROM products WHERE 1=1";
const params: any[] = [];
if (req.query.category) {
query += " AND category = ?";
params.push(req.query.category);
}
if (req.query.min_price) {
query += " AND price >= ?";
params.push(parseFloat(req.query.min_price as string));
}
if (req.query.max_price) {
query += " AND price <= ?";
params.push(parseFloat(req.query.max_price as string));
}
// Sort: -price means DESC, name means ASC
if (req.query.sort) {
const sortFields = (req.query.sort as string).split(",").map(field => {
if (field.startsWith("-")) return `${field.slice(1)} DESC`;
return `${field} ASC`;
});
query += ` ORDER BY ${sortFields.join(", ")}`;
}
const products = await db.query(query, params);
res.json({ data: products });
});
API Versioning Strategies
// Strategy 1: URL versioning (most common, most visible)
app.use("/api/v1/users", usersV1Router);
app.use("/api/v2/users", usersV2Router);
// Strategy 2: Header versioning
app.use("/api/users", (req, res, next) => {
const version = req.headers["api-version"] || "1";
if (version === "2") return usersV2Router(req, res, next);
return usersV1Router(req, res, next);
});
// Strategy 3: Accept header versioning
// Client sends: Accept: application/vnd.myapi.v2+json
Authentication: JWT Implementation
npm install jsonwebtoken bcryptjs
npm install --save-dev @types/jsonwebtoken @types/bcryptjs
import jwt from "jsonwebtoken";
import bcrypt from "bcryptjs";
const JWT_SECRET = process.env.JWT_SECRET!;
const JWT_EXPIRES_IN = "24h";
// Login endpoint
app.post("/api/v1/auth/login", async (req, res) => {
const { email, password } = req.body;
const user = await db.query("SELECT * FROM users WHERE email = ?", [email]);
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ error: { message: "Invalid credentials" } });
}
const accessToken = jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
const refreshToken = jwt.sign(
{ sub: user.id, type: "refresh" },
JWT_SECRET,
{ expiresIn: "7d" }
);
res.json({
data: {
access_token: accessToken,
refresh_token: refreshToken,
token_type: "Bearer",
expires_in: 86400,
},
});
});
// Authentication middleware
function authenticate(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ error: { message: "Missing token" } });
}
try {
const token = authHeader.split(" ")[1];
const decoded = jwt.verify(token, JWT_SECRET) as { sub: number; role: string };
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: { message: "Invalid or expired token" } });
}
}
// Protected route
app.get("/api/v1/profile", authenticate, async (req, res) => {
const user = await db.query("SELECT id, name, email FROM users WHERE id = ?", [req.user.sub]);
res.json({ data: user });
});
Rate Limiting
// Express rate limiter
import rateLimit from "express-rate-limit";
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true, // Return rate limit info in headers
legacyHeaders: false,
message: {
error: {
type: "https://api.example.com/errors/rate-limit",
title: "Too Many Requests",
status: 429,
detail: "You have exceeded the rate limit. Try again in 15 minutes.",
},
},
});
app.use("/api/", apiLimiter);
// Stricter limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10, // 10 login attempts per hour
});
app.use("/api/v1/auth/login", authLimiter);
# Nginx rate limiting
http {
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
proxy_pass http://localhost:3000;
}
}
}
Error Response Format (RFC 7807 Problem Details)
// Standardized error response
interface ProblemDetail {
type: string; // URI identifying the error type
title: string; // Human-readable summary
status: number; // HTTP status code
detail: string; // Detailed explanation
instance?: string; // URI of the specific occurrence
errors?: Array<{ // Validation errors
field: string;
message: string;
code: string;
}>;
}
// Error handler middleware
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
if (err instanceof ValidationError) {
return res.status(422).json({
type: "https://api.example.com/errors/validation",
title: "Validation Failed",
status: 422,
detail: "One or more fields have invalid values.",
errors: err.details.map(d => ({
field: d.path,
message: d.message,
code: "INVALID_VALUE",
})),
});
}
// Generic 500 error (never expose stack traces in production)
res.status(500).json({
type: "https://api.example.com/errors/internal",
title: "Internal Server Error",
status: 500,
detail: process.env.NODE_ENV === "production"
? "An unexpected error occurred."
: err.message,
});
});
Input Validation with Zod
npm install zod
import { z } from "zod";
const createUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
password: z.string().min(8).regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*d)/, {
message: "Password must contain uppercase, lowercase, and number",
}),
role: z.enum(["user", "admin"]).default("user"),
age: z.number().int().min(13).max(120).optional(),
});
type CreateUserInput = z.infer<typeof createUserSchema>;
// Validation middleware
function validate(schema: z.ZodSchema) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
type: "https://api.example.com/errors/validation",
title: "Validation Failed",
status: 422,
detail: "Request body validation failed.",
errors: result.error.issues.map(issue => ({
field: issue.path.join("."),
message: issue.message,
code: issue.code,
})),
});
}
req.body = result.data;
next();
};
}
app.post("/api/v1/users", validate(createUserSchema), createUser);
API Documentation with OpenAPI/Swagger
# openapi.yaml
openapi: 3.0.3
info:
title: My API
version: 1.0.0
description: RESTful API for managing users and products
servers:
- url: https://api.example.com/v1
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
maximum: 100
responses:
'200':
description: List of users
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/User'
post:
summary: Create a new user
tags: [Users]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUserInput'
responses:
'201':
description: User created
components:
schemas:
User:
type: object
properties:
id:
type: integer
name:
type: string
email:
type: string
format: email
CreateUserInput:
type: object
required: [name, email, password]
properties:
name:
type: string
minLength: 2
email:
type: string
format: email
password:
type: string
minLength: 8
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
GraphQL vs REST Comparison
| Feature | REST | GraphQL |
| Data fetching | Fixed structure per endpoint | Client specifies exact fields needed |
| Over/under-fetching | Common problem | Solved — client controls response shape |
| Number of endpoints | Many (one per resource/action) | Single endpoint (/graphql) |
| Caching | Built-in HTTP caching | More complex (needs Apollo Cache, etc.) |
| File uploads | Native support | Requires extra setup |
| Learning curve | Low — uses familiar HTTP | Higher — new query language |
| Real-time | WebSockets or SSE (separate) | Built-in subscriptions |
| Error handling | HTTP status codes | Always 200 — errors in response body |
| Best for | Simple CRUD, public APIs, microservices | Complex data graphs, mobile apps, dashboards |
API Security Checklist
- Always use HTTPS — never transmit tokens over HTTP
- Validate all input — use schemas (Zod, Joi) on every endpoint
- Authenticate every request — JWT or OAuth 2.0
- Authorize at resource level — check ownership, not just authentication
- Rate limit all endpoints — especially auth and public endpoints
- Set security headers — use helmet.js for Express
- Log all requests — timestamp, IP, user ID, endpoint, status code
- Never expose stack traces — generic errors in production
- Use parameterized queries — never concatenate user input into SQL
- Set CORS properly — whitelist specific origins
- Expire tokens — short-lived access tokens, longer refresh tokens
- Version your API — never break existing clients
Quick Reference Cheat Sheet
| Method | Endpoint Pattern | Action | Status Code |
| GET | /resources | List all | 200 |
| GET | /resources/:id | Get one | 200 or 404 |
| POST | /resources | Create | 201 |
| PUT | /resources/:id | Replace | 200 |
| PATCH | /resources/:id | Update | 200 |
| DELETE | /resources/:id | Delete | 204 |
| Header | Purpose |
Authorization: Bearer <token> | Authentication |
Content-Type: application/json | Request body format |
Accept: application/json | Expected response format |
X-RateLimit-Remaining | Requests left in window |
X-Request-Id | Request tracing |