REpresentational State Transfer was defined by Roy Fielding in his 2000 doctoral dissertation. It is an architectural style, not a protocol or specification. REST describes how distributed hypermedia systems should behave when designed well.
A REST API exposes resources (nouns) at stable URIs and lets clients manipulate them using standard HTTP methods (verbs). The server transfers representations of resource state — typically JSON.
POST /getUser), tight couplingREST remains the dominant style for public APIs because of simplicity, cacheability, and tooling maturity.
REST maps CRUD operations to HTTP verbs. Understanding safety (no side effects) and idempotency (same result if repeated) is critical for correct API design.
| Method | Semantics | Safe | Idempotent | Request Body | Example |
|---|---|---|---|---|---|
| GET | Read a resource | Yes | Yes | No | GET /users/42 |
| POST | Create a resource | No | No | Yes | POST /users |
| PUT | Replace a resource entirely | No | Yes | Yes | PUT /users/42 |
| PATCH | Partial update | No | No* | Yes | PATCH /users/42 |
| DELETE | Remove a resource | No | Yes | Rare | DELETE /users/42 |
GET and HEAD never modify server state. Clients and intermediaries can retry, cache, and prefetch them freely.
PUT and DELETE produce the same result no matter how many times you call them. This is essential for retry logic and fault tolerance.
PATCH is not inherently idempotent. {"op":"increment","path":"/views"} changes state on each call. JSON Merge Patch is idempotent; JSON Patch may not be.
/users not /getUsers/orders not /order/line-items not /lineItems/users/42/orders# Good URL patterns
GET /api/v1/users # list users
GET /api/v1/users/42 # single user
GET /api/v1/users/42/orders # user's orders
POST /api/v1/users # create user
DELETE /api/v1/users/42 # delete user
# Bad URL patterns
GET /api/v1/getUser?id=42 # verb in URL
POST /api/v1/user/create # verb + singular
GET /api/v1/users/42/orders/7/items/3/tags # too deep
?status=active&role=admin?sort=-created_at,name?page=2&per_page=25?fields=id,name,email?q=john+doeIf a resource can exist independently, give it its own top-level endpoint. Use nesting only for true ownership relationships.
# Order belongs to user — nested OK
GET /users/42/orders
# Order can be accessed directly too
GET /orders/789
# Tag is independent — don't nest 3 levels
GET /tags/5 (not /users/42/orders/7/tags/5)
"2025-03-15T10:30:00Z"null for absent values, don't omit keys// POST /api/v1/users — request body
{
"firstName": "Alice",
"lastName": "Chen",
"email": "alice@example.com",
"role": "editor"
}
// Response — single resource
{
"data": {
"id": 42,
"firstName": "Alice",
"lastName": "Chen",
"email": "alice@example.com",
"role": "editor",
"createdAt": "2025-03-15T10:30:00Z"
}
}
Wrap responses in a consistent envelope with data, meta, and errors keys. This makes parsing predictable for every client.
// Response — collection with metadata
{
"data": [
{ "id": 1, "name": "Widget A" },
{ "id": 2, "name": "Widget B" }
],
"meta": {
"totalCount": 87,
"page": 1,
"perPage": 25,
"totalPages": 4
},
"links": {
"self": "/api/v1/products?page=1",
"next": "/api/v1/products?page=2",
"last": "/api/v1/products?page=4"
}
}
Clients send Accept: application/json. Servers respond with Content-Type: application/json. Support Accept header for multiple formats. Return 406 Not Acceptable if the format is unsupported.
Use the most specific status code that applies. Clients rely on these for control flow, retry logic, and error handling.
| Code | Name | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST (include Location header) |
| 204 | No Content | Successful DELETE (no body) |
| 301 | Moved Permanently | Resource URL changed forever |
| 304 | Not Modified | Conditional GET, cache still valid |
| 400 | Bad Request | Malformed syntax, invalid body |
| 401 | Unauthorized | Missing or invalid credentials |
| 403 | Forbidden | Authenticated but not authorised |
| 404 | Not Found | Resource does not exist |
| 409 | Conflict | Duplicate, version conflict |
| 422 | Unprocessable Entity | Validation errors |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unexpected server failure |
| 503 | Service Unavailable | Maintenance, overloaded |
The request was received, understood, and accepted. Always return the appropriate 2xx code. 200 is not a catch-all.
The client sent something wrong. Include a descriptive error body. Never return 200 with an error in the body.
Something broke on the server. Log the full error internally but expose only a safe message to clients. Include a Retry-After header for 503.
Returning 200 with {"success": false} defeats HTTP semantics. Proxies, caches, and monitoring tools all rely on status codes for correct behaviour.
A standard format for HTTP API error responses. Provides machine-readable error details with a consistent structure.
// 422 Unprocessable Entity
{
"type": "https://api.example.com/errors/validation",
"title": "Validation Error",
"status": 422,
"detail": "Request body contains invalid fields",
"instance": "/api/v1/users",
"errors": [
{
"field": "email",
"message": "must be a valid email address",
"value": "not-an-email"
},
{
"field": "age",
"message": "must be at least 18",
"value": 12
}
]
}
// Express error handler
app.use((err, req, res, next) => {
const status = err.statusCode || 500;
res.status(status).json({
type: `https://api.example.com/errors/${err.code}`,
title: err.title || 'Internal Server Error',
status,
detail: status === 500
? 'An unexpected error occurred'
: err.message,
instance: req.originalUrl,
...(err.errors && { errors: err.errors })
});
});
RFC 7807 specifies application/problem+json as the media type. Many APIs use plain application/json with the same structure.
Never return unbounded collections. Choose a pagination strategy based on data characteristics and client needs.
| Strategy | Params | Pros | Cons |
|---|---|---|---|
| Offset | ?page=3&per_page=25 | Simple, jump to any page | Slow on large tables (OFFSET 100000), drift on inserts |
| Cursor | ?cursor=eyJpZCI6NDJ9&limit=25 | Stable, no drift, fast | No random page access, opaque tokens |
| Keyset | ?after_id=42&limit=25 | Fast (index seek), transparent | Requires sortable unique key, forward-only |
HTTP/1.1 200 OK
Link: </api/v1/users?cursor=abc&limit=25>; rel="next",
</api/v1/users?cursor=xyz&limit=25>; rel="prev"
X-Total-Count: 1482
Link headers keep pagination metadata out of the body. GitHub's API uses this pattern extensively.
{
"data": [ ... ],
"meta": {
"totalCount": 1482,
"nextCursor": "eyJpZCI6NjcsImNyZWF0ZWQiOiIyMDI1LTAzIn0=",
"hasMore": true
}
}
Embed pagination metadata in the response body when Link headers are insufficient for your client.
Use query parameters for simple equality. Use operator suffixes or bracket notation for richer queries.
# Simple equality
GET /api/v1/products?category=electronics&in_stock=true
# Operator suffixes
GET /api/v1/products?price_gte=10&price_lte=100
# Bracket notation (JSON:API style)
GET /api/v1/products?filter[price][gte]=10&filter[price][lte]=100
# Search
GET /api/v1/products?q=wireless+headphones
Let clients request only the fields they need, reducing payload size and bandwidth.
# Only return id, name, and price
GET /api/v1/products?fields=id,name,price
# JSON:API style
GET /api/v1/products?fields[products]=id,name,price
Use a sort parameter with comma-separated fields. Prefix with - for descending order.
# Sort by created_at descending, then name ascending
GET /api/v1/products?sort=-created_at,name
# Explicit direction style
GET /api/v1/products?sort=created_at:desc,name:asc
400 for unknown field names// Express query parser
const allowedSort = ['name', 'price', 'created_at'];
const sortField = allowedSort.includes(req.query.sort)
? req.query.sort : 'created_at';
const order = req.query.order === 'desc' ? 'DESC' : 'ASC';
const results = await db.query(
`SELECT * FROM products ORDER BY ${sortField} ${order}`
);
APIs evolve. Breaking changes need a versioning strategy so existing clients keep working while new clients get new features.
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URL Path | /api/v1/users | Visible, simple routing, cacheable | Violates REST (URI should identify resource, not version) |
| Custom Header | API-Version: 2 | Clean URLs, explicit | Hidden, harder to test in browser, not cacheable by URL |
| Accept Header | Accept: application/vnd.api.v2+json | Most RESTful (content negotiation) | Complex, unfamiliar to many developers |
| Query Param | /api/users?version=2 | Easy to add, easy to test | Optional params can be forgotten, cache issues |
const express = require('express');
const app = express();
// Version 1
const v1Router = express.Router();
v1Router.get('/users', v1.listUsers);
// Version 2
const v2Router = express.Router();
v2Router.get('/users', v2.listUsers);
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
Sunset headerHTTP/1.1 200 OK
Sunset: Sat, 01 Nov 2025 00:00:00 GMT
Deprecation: true
Link: </api/v2/users>; rel="successor-version"
The Sunset header (RFC 8594) tells clients when an endpoint will be removed.
| Method | Best For |
|---|---|
| API Key | Server-to-server, simple integrations |
| Bearer Token (JWT) | User-facing APIs, stateless auth |
| OAuth 2.0 | Third-party access, scoped permissions |
| Basic Auth | Internal tools (over HTTPS only) |
# API Key (header)
GET /api/v1/data
X-API-Key: sk_live_abc123def456
# Bearer Token (JWT)
GET /api/v1/users/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
# Basic Auth
GET /api/v1/admin
Authorization: Basic YWRtaW46cGFzc3dvcmQ=
// JWT middleware in Express
const jwt = require('jsonwebtoken');
function authenticate(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({
type: '/errors/unauthorized',
title: 'Missing or invalid token',
status: 401
});
}
try {
const token = header.slice(7);
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch (err) {
res.status(401).json({
type: '/errors/unauthorized',
title: 'Token expired or invalid',
status: 401
});
}
}
Scopes limit what a token can do: read:users, write:orders, admin. Return 403 Forbidden when a valid token lacks the required scope.
Rate limiting protects your API from abuse, ensures fair usage, and prevents cascading failures. Every production API needs it.
| Algorithm | How It Works | Trade-off |
|---|---|---|
| Fixed Window | N requests per time window | Simple, but burst at window edges |
| Sliding Window | Rolling window of weighted counts | Smooth, slight complexity |
| Token Bucket | Tokens refill at steady rate, burst OK | Allows bursts, widely used |
| Leaky Bucket | Requests drain at fixed rate | Smoothest output, queues requests |
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 742
X-RateLimit-Reset: 1711036800
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
// Express rate limiting with express-rate-limit
const rateLimit = require('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: {
type: '/errors/rate-limit',
title: 'Too Many Requests',
status: 429,
detail: 'Rate limit exceeded. Try again later.'
}
});
app.use('/api/', apiLimiter);
// Tiered limits per plan
const premiumLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5000,
keyGenerator: (req) => req.user?.apiKey
});
HATEOAS (Hypermedia As The Engine Of Application State) means the API response includes links that tell the client what actions are available next. The client never hard-codes URLs.
// GET /api/v1/orders/789
{
"data": {
"id": 789,
"status": "pending",
"total": 59.99
},
"links": {
"self": { "href": "/api/v1/orders/789" },
"cancel": { "href": "/api/v1/orders/789/cancel", "method": "POST" },
"pay": { "href": "/api/v1/orders/789/pay", "method": "POST" },
"customer": { "href": "/api/v1/users/42" }
}
}
// After payment — cancel link disappears
{
"data": { "id": 789, "status": "paid", "total": 59.99 },
"links": {
"self": { "href": "/api/v1/orders/789" },
"refund": { "href": "/api/v1/orders/789/refund", "method": "POST" },
"receipt": { "href": "/api/v1/orders/789/receipt" }
}
}
Most public APIs implement Level 2 only. Full HATEOAS adds complexity and many clients ignore links. GitHub, PayPal, and Spring Data REST are notable exceptions that implement HATEOAS.
# Public resource — cacheable for 1 hour
Cache-Control: public, max-age=3600
# Private user data — only browser can cache
Cache-Control: private, max-age=600
# Never cache (auth endpoints, real-time data)
Cache-Control: no-store
# Cache but revalidate every time
Cache-Control: no-cache
# First response
HTTP/1.1 200 OK
ETag: "a1b2c3d4"
# Subsequent request — conditional
GET /api/v1/products/42
If-None-Match: "a1b2c3d4"
# Server responds — not changed
HTTP/1.1 304 Not Modified
Saves bandwidth — the server only sends the full body if the resource actually changed.
# First response
HTTP/1.1 200 OK
Last-Modified: Thu, 15 Mar 2025 10:30:00 GMT
# Conditional request
GET /api/v1/products/42
If-Modified-Since: Thu, 15 Mar 2025 10:30:00 GMT
# 304 if unchanged, 200 with body if changed
// Express ETag and caching middleware
app.get('/api/v1/products/:id', async (req, res) => {
const product = await db.findById(req.params.id);
const etag = crypto
.createHash('md5')
.update(JSON.stringify(product))
.digest('hex');
res.set({
'ETag': `"${etag}"`,
'Cache-Control': 'public, max-age=300',
'Vary': 'Accept, Authorization'
});
if (req.headers['if-none-match'] === `"${etag}"`) {
return res.status(304).end();
}
res.json({ data: product });
});
Put a CDN (CloudFront, Fastly) in front of GET endpoints. Use Vary header to cache different representations. Invalidate on write operations.
The OpenAPI Specification (OAS) is the industry standard for describing REST APIs. Write it in YAML or JSON and generate docs, SDKs, and mock servers automatically.
openapi: 3.0.3
info:
title: Products API
version: 1.0.0
paths:
/api/v1/products:
get:
summary: List products
parameters:
- name: category
in: query
schema:
type: string
- name: page
in: query
schema:
type: integer
default: 1
responses:
'200':
description: A list of products
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Product'
// Auto-generate Swagger in Express
const swaggerJsdoc = require('swagger-jsdoc');
const swaggerUi = require('swagger-ui-express');
const specs = swaggerJsdoc({
definition: {
openapi: '3.0.3',
info: { title: 'My API', version: '1.0.0' }
},
apis: ['./routes/*.js'] // JSDoc annotations
});
app.use('/docs', swaggerUi.serve,
swaggerUi.setup(specs));
A complete REST API skeleton with Express — routes, controllers, validation, and error handling following every pattern discussed.
// server.js — app setup
const express = require('express');
const app = express();
app.use(express.json());
app.use(cors());
// Routes
app.use('/api/v1/users', require('./routes/users'));
app.use('/api/v1/products', require('./routes/products'));
// 404 handler
app.use((req, res) => {
res.status(404).json({
type: '/errors/not-found',
title: 'Not Found',
status: 404,
detail: `No route matches ${req.method} ${req.path}`
});
});
// Global error handler
app.use((err, req, res, next) => {
const status = err.statusCode || 500;
res.status(status).json({
type: `/errors/${err.code || 'server-error'}`,
title: err.title || 'Internal Server Error',
status,
detail: status === 500 ? 'Something went wrong' : err.message,
...(err.errors && { errors: err.errors })
});
});
app.listen(3000);
// routes/users.js — full CRUD
const router = require('express').Router();
const { body, validationResult } = require('express-validator');
// List users (paginated, filtered, sorted)
router.get('/', async (req, res, next) => {
try {
const { page = 1, per_page = 25, sort = '-created_at' } = req.query;
const { data, total } = await User.findAll({
page, perPage: per_page, sort
});
res.json({
data,
meta: { totalCount: total, page: +page, perPage: +per_page }
});
} catch (err) { next(err); }
});
// Create user (with validation)
router.post('/',
body('email').isEmail(),
body('firstName').trim().notEmpty(),
async (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({
type: '/errors/validation',
title: 'Validation Error',
status: 422,
errors: errors.array()
});
}
try {
const user = await User.create(req.body);
res.status(201)
.location(`/api/v1/users/${user.id}`)
.json({ data: user });
} catch (err) { next(err); }
}
);
Requiring many round-trips to accomplish a single task. Loading a dashboard that needs 15 API calls is a sign of poor resource design.
# Bad — 4 requests to load a user profile
GET /users/42
GET /users/42/address
GET /users/42/preferences
GET /users/42/avatar
# Better — compound document or expand param
GET /users/42?expand=address,preferences
?fields=id,nameexpand or include parameters.# Anti-pattern
POST /api/createUser
POST /api/deleteUser?id=42
GET /api/getUserById?id=42
# Correct — let HTTP methods be the verbs
POST /api/v1/users
DELETE /api/v1/users/42
GET /api/v1/users/42
If PUT /orders/42 creates a new order on each call instead of replacing, clients can't safely retry on network failures. This leads to duplicate records and inconsistent state.
// Anti-pattern: PUT creates duplicates
app.put('/orders/:id', async (req, res) => {
await Order.create(req.body); // WRONG — always creates
});
// Correct: PUT is idempotent replace
app.put('/orders/:id', async (req, res) => {
await Order.upsert(req.params.id, req.body);
});
{"error": true}camelCase, snake_case, plural/singularBuild a complete REST API with Express. Start with 3–4 resources, implement proper status codes, validation, pagination, and error handling. Add OpenAPI docs. Deploy behind a reverse proxy with rate limiting and caching. Then explore GraphQL, gRPC, and event-driven architectures to understand when REST is — and isn't — the right choice.