What is a REST API?
REST (Representational State Transfer) is an architectural style for networked APIs. Instead of remote procedure calls, you model your domain as resources — nouns like /users or /orders/42 — and act on them with standard HTTP methods. A well-designed REST API is predictable: the URL identifies what, the method identifies the action, and the status code reports the outcome.
Core Principles
- Resources & representations — each resource has a URI; the server returns a representation (usually JSON). Use plural nouns:
/users/42/orders, not verbs like/getUserOrders. - Statelessness — every request carries all context needed to serve it (auth token, params). The server keeps no client session between requests, which makes horizontal scaling trivial.
- Uniform interface — the same small set of methods and status codes across all resources.
- Client–server separation and cacheability — responses declare whether they can be cached.
HTTP Methods & Idempotency
Each method has defined semantics. Safe methods don't modify state. Idempotent methods produce the same result no matter how many times they're repeated — critical for safe retries after a network timeout.
| Method | Purpose | Safe | Idempotent |
|---|---|---|---|
GET | Read a resource | Yes | Yes |
POST | Create a new resource | No | No |
PUT | Replace a resource fully | No | Yes |
PATCH | Update part of a resource | No | Usually not |
DELETE | Remove a resource | No | Yes |
Why idempotency matters
If a client sends a PUT and the response is lost, it can safely resend — the end state is identical. A POST cannot be blindly retried (you'd create duplicates). For safe POST retries, accept an Idempotency-Key header and deduplicate on the server.
Status Codes
Status codes are grouped by first digit: 2xx success, 3xx redirect, 4xx client error, 5xx server error. Return the most specific code — a client should be able to branch on the code alone.
| Code | Meaning | When to use |
|---|---|---|
200 OK | Success | GET / PUT / PATCH succeeded |
201 Created | Resource created | POST created a new resource |
204 No Content | Success, empty body | DELETE succeeded |
400 Bad Request | Malformed request | Validation failed |
401 Unauthorized | Not authenticated | Missing / invalid credentials |
403 Forbidden | Not authorized | Authenticated but no permission |
404 Not Found | Resource absent | Unknown ID or route |
409 Conflict | State conflict | Duplicate, version mismatch |
422 Unprocessable | Semantic error | Valid syntax, invalid data |
429 Too Many | Rate limited | Client exceeded quota |
500 Server Error | Unhandled failure | Bug or dependency failure |
503 Unavailable | Temporarily down | Overload, maintenance |
Building an API with Express
Express is the most common Node.js web framework. It's built around middleware — functions that receive (req, res, next) and run in order for each request. Routes are middleware bound to a method and path.
import express from 'express';
const app = express();
app.use(express.json()); // parse JSON bodies
// Logging middleware — runs for every request
app.use((req, res, next) => {
console.log(`${req.method} ${req.path}`);
next(); // pass control onward
});
// Routes on a versioned resource
const router = express.Router();
router.get('/users/:id', async (req, res, next) => {
try {
const user = await db.users.find(req.params.id);
if (!user) return res.status(404).json({ error: 'not found' });
res.json(user);
} catch (err) { next(err); } // forward to error handler
});
router.post('/users', async (req, res, next) => {
try {
const created = await db.users.create(req.body);
res.status(201).location(`/v1/users/${created.id}`).json(created);
} catch (err) { next(err); }
});
app.use('/v1', router);
Centralized Error Handling
Express identifies an error-handling middleware by its four arguments. Register it last so every next(err) funnels here. Map known error types to status codes and never leak stack traces to clients.
app.use((err, req, res, next) => {
const status = err.status ?? 500;
if (status >= 500) console.error(err); // log server errors
res.status(status).json({
error: err.publicMessage ?? 'Internal Server Error'
});
});
Request Validation
Never trust client input. Validate the request body, query, and params at the edge — before any business logic — and return 400 or 422 with a clear message. Schema libraries like Zod make this declarative and type-safe.
import { z } from 'zod';
const CreateUser = z.object({
email: z.string().email(),
age: z.number().int().min(0).max(150),
});
function validate(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({ errors: result.error.issues });
}
req.body = result.data; // sanitized, typed data
next();
};
}
app.post('/v1/users', validate(CreateUser), createUserHandler);
Pagination & Filtering
Never return an unbounded list. Use query parameters for filtering, sorting, and paging. Cursor-based pagination (opaque token pointing to the last item) is more robust than offset-based for large, changing datasets because it avoids skipped or duplicated rows.
// GET /v1/orders?status=paid&sort=-createdAt&limit=20&cursor=abc123
app.get('/v1/orders', async (req, res) => {
const limit = Math.min(Number(req.query.limit) || 20, 100);
const { items, nextCursor } = await db.orders.page({
status: req.query.status,
sort: req.query.sort,
cursor: req.query.cursor,
limit,
});
res.json({ data: items, nextCursor }); // client passes nextCursor back
});
Versioning
APIs evolve, but existing clients must not break. Version the API so you can ship breaking changes behind a new version. The most common approach is a URL path prefix (/v1/users, /v2/users) — simple and cache-friendly. Alternatives include a custom header or an Accept media type. Only bump the version for breaking changes; additive fields are backward-compatible.
Authentication & Headers
Because REST is stateless, each request must authenticate itself. The standard is a bearer token in the Authorization header, typically a JWT or opaque access token. Authenticate (who are you? → 401) before authorizing (are you allowed? → 403).
// Authorization: Bearer eyJhbGciOi...
function authenticate(req, res, next) {
const header = req.get('authorization') ?? '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return res.status(401).json({ error: 'missing token' });
try {
req.user = verifyJwt(token); // throws if invalid/expired
next();
} catch {
res.status(401).json({ error: 'invalid token' });
}
}
function requireRole(role) {
return (req, res, next) =>
req.user?.roles?.includes(role)
? next()
: res.status(403).json({ error: 'forbidden' });
}
app.delete('/v1/users/:id', authenticate, requireRole('admin'), deleteUser);
CORS
Cross-Origin Resource Sharing governs whether a browser lets a page on origin A call an API on origin B. The server opts in via Access-Control-Allow-Origin and related headers. For non-simple requests the browser first sends a OPTIONS preflight. Allow-list specific origins in production — avoid the wildcard * when credentials are involved.
import cors from 'cors';
app.use(cors({
origin: ['https://app.example.com'], // allow-list, not '*'
methods: ['GET', 'POST', 'PUT', 'DELETE'],
credentials: true,
}));
Rate Limiting
Protect the API from abuse and accidental floods by capping requests per client per window. Respond with 429 Too Many Requests and a Retry-After header so clients can back off. In a multi-instance deployment, back the counter with a shared store like Redis.
import rateLimit from 'express-rate-limit';
app.use(rateLimit({
windowMs: 60_000, // 1 minute
limit: 100, // 100 requests per window per IP
standardHeaders: true, // RateLimit-* headers
message: { error: 'too many requests' },
}));
REST vs GraphQL
GraphQL is an alternative API style: a single endpoint where the client specifies exactly which fields it wants. Neither is universally better — choose based on your clients and data shape.
| Aspect | REST | GraphQL |
|---|---|---|
| Endpoints | Many, one per resource | One (POST /graphql) |
| Data fetching | Fixed response shape | Client picks fields |
| Over/under-fetch | Common; multiple round-trips | Avoided; one query |
| Caching | Easy (HTTP + URLs) | Harder (custom layer) |
| Status codes | Rich per-request | Usually 200 + errors array |
| Best for | Public / cacheable APIs | Rich clients, varied data needs |
Documenting with OpenAPI
OpenAPI (formerly Swagger) is a standard, machine-readable specification of your REST API — paths, methods, schemas, and responses. From one spec you can generate interactive docs, client SDKs, mock servers, and request validators. Treat it as a contract that stays in sync with the code.
openapi: 3.1.0
info: { title: Users API, version: 1.0.0 }
paths:
/v1/users/{id}:
get:
summary: Get a user
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200': { description: OK }
'404': { description: Not Found }
Design checklist
Plural noun resources, correct methods, precise status codes, validate all input, paginate every list, version the base path, authenticate then authorize, allow-list CORS origins, rate-limit, and publish an OpenAPI spec. Consistency across endpoints matters more than any single clever choice.
Practice Exercises
- Design the resource URLs and HTTP methods for a blog API (posts, comments, authors) — decide which endpoints are idempotent.
- Build an Express CRUD API for a
/v1/tasksresource with correct status codes (201 on create, 204 on delete, 404 on missing). - Add Zod validation to the create/update routes and return
422with a structured errors array. - Implement cursor-based pagination for the task list and return a
nextCursor. - Add bearer-token authentication middleware plus a
requireRole('admin')guard on the delete route. - Add CORS allow-listing and a rate limiter, then write an OpenAPI 3.1 spec for the two main endpoints.