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

CodeNameWhen to Use
200OKSuccessful GET, PUT, PATCH, or DELETE
201CreatedSuccessful POST that created a resource
204No ContentSuccessful DELETE with no response body
301Moved PermanentlyResource URL has permanently changed
304Not ModifiedCached version is still valid (ETag/If-Modified)
400Bad RequestInvalid request body or parameters
401UnauthorizedMissing or invalid authentication
403ForbiddenAuthenticated but insufficient permissions
404Not FoundResource does not exist
405Method Not AllowedHTTP method not supported for this endpoint
409ConflictResource conflict (e.g., duplicate email)
422Unprocessable EntityValidation errors in request body
429Too Many RequestsRate limit exceeded
500Internal Server ErrorUnexpected server error
502Bad GatewayUpstream server error
503Service UnavailableServer 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

FeatureRESTGraphQL
Data fetchingFixed structure per endpointClient specifies exact fields needed
Over/under-fetchingCommon problemSolved — client controls response shape
Number of endpointsMany (one per resource/action)Single endpoint (/graphql)
CachingBuilt-in HTTP cachingMore complex (needs Apollo Cache, etc.)
File uploadsNative supportRequires extra setup
Learning curveLow — uses familiar HTTPHigher — new query language
Real-timeWebSockets or SSE (separate)Built-in subscriptions
Error handlingHTTP status codesAlways 200 — errors in response body
Best forSimple CRUD, public APIs, microservicesComplex 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

MethodEndpoint PatternActionStatus Code
GET/resourcesList all200
GET/resources/:idGet one200 or 404
POST/resourcesCreate201
PUT/resources/:idReplace200
PATCH/resources/:idUpdate200
DELETE/resources/:idDelete204
HeaderPurpose
Authorization: Bearer <token>Authentication
Content-Type: application/jsonRequest body format
Accept: application/jsonExpected response format
X-RateLimit-RemainingRequests left in window
X-Request-IdRequest tracing