Learn/software dev/Backend & REST APIs
Intermediate~20 min read

Backend & REST APIs

REST principles and statelessness, HTTP methods and idempotency, status codes, an Express server with middleware and error handling, validation, pagination, versioning, auth, CORS, rate limiting, REST vs GraphQL, and OpenAPI.

RESTHTTPExpressAPI Design

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.

MethodPurposeSafeIdempotent
GETRead a resourceYesYes
POSTCreate a new resourceNoNo
PUTReplace a resource fullyNoYes
PATCHUpdate part of a resourceNoUsually not
DELETERemove a resourceNoYes

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.

CodeMeaningWhen to use
200 OKSuccessGET / PUT / PATCH succeeded
201 CreatedResource createdPOST created a new resource
204 No ContentSuccess, empty bodyDELETE succeeded
400 Bad RequestMalformed requestValidation failed
401 UnauthorizedNot authenticatedMissing / invalid credentials
403 ForbiddenNot authorizedAuthenticated but no permission
404 Not FoundResource absentUnknown ID or route
409 ConflictState conflictDuplicate, version mismatch
422 UnprocessableSemantic errorValid syntax, invalid data
429 Too ManyRate limitedClient exceeded quota
500 Server ErrorUnhandled failureBug or dependency failure
503 UnavailableTemporarily downOverload, 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.

javascript
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.

javascript
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.

javascript
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.

javascript
// 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).

javascript
// 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.

javascript
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.

javascript
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.

AspectRESTGraphQL
EndpointsMany, one per resourceOne (POST /graphql)
Data fetchingFixed response shapeClient picks fields
Over/under-fetchCommon; multiple round-tripsAvoided; one query
CachingEasy (HTTP + URLs)Harder (custom layer)
Status codesRich per-requestUsually 200 + errors array
Best forPublic / cacheable APIsRich 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.

yaml
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

  1. Design the resource URLs and HTTP methods for a blog API (posts, comments, authors) — decide which endpoints are idempotent.
  2. Build an Express CRUD API for a /v1/tasks resource with correct status codes (201 on create, 204 on delete, 404 on missing).
  3. Add Zod validation to the create/update routes and return 422 with a structured errors array.
  4. Implement cursor-based pagination for the task list and return a nextCursor.
  5. Add bearer-token authentication middleware plus a requireRole('admin') guard on the delete route.
  6. Add CORS allow-listing and a rate limiter, then write an OpenAPI 3.1 spec for the two main endpoints.

Section navigation