All posts
APIBackendNode.jsArchitecture

API Design Patterns Every Fullstack Developer Should Know

Good API design is what separates maintainable systems from spaghetti. Here are the patterns I use in every production backend.

20 January 20264 min read

A well-designed API is a joy to work with. A poorly designed one becomes a maintenance nightmare. After years of building and consuming APIs, here are the patterns I reach for every time.

1. Resource-Oriented Design

Think in resources (nouns), not actions (verbs):

// Bad — verb-based
POST /api/getUserById
POST /api/createNewUser
POST /api/deleteUserAccount

// Good — resource-based
GET    /api/users/:id
POST   /api/users
DELETE /api/users/:id

HTTP methods already express the action. Your URL should express the resource.

2. Consistent Error Responses

Every error response should have the same shape. Clients shouldn't need to handle 10 different error formats:

interface ApiError {
  error: {
    code: string;      // machine-readable (e.g. "USER_NOT_FOUND")
    message: string;   // human-readable
    details?: unknown; // optional extra context
  };
  requestId: string;   // for debugging/support
}

// Example
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid request body",
    "details": [
      { "field": "email", "message": "Invalid email address" }
    ]
  },
  "requestId": "req_01HXYZ..."
}

3. Pagination — Cursor Over Offset

Offset pagination (?page=2&limit=20) breaks when data changes between requests. Cursor pagination is stable:

// Cursor-based response envelope
interface PaginatedResponse<T> {
  data: T[];
  pagination: {
    hasMore: boolean;
    nextCursor: string | null;
    total?: number; // optional — expensive to compute
  };
}

// Query: GET /api/posts?cursor=eyJpZCI6IjEyMyJ9&limit=20

4. Versioning from Day One

Even if you're the only consumer, version your API:

/api/v1/users
/api/v2/users  ← breaking changes go here

This lets you evolve the API without breaking existing clients. I prefer URL versioning for public APIs (it's explicit) and header versioning for internal APIs.

5. Request Validation at the Boundary

Validate everything at the API boundary before it reaches your business logic. Zod makes this clean:

import { z } from "zod";

const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
  tags: z.array(z.string()).max(10).default([]),
  publishedAt: z.coerce.date().optional(),
});

export async function POST(req: Request) {
  const body = await req.json();
  const parsed = CreatePostSchema.safeParse(body);

  if (!parsed.success) {
    return Response.json(
      { error: { code: "VALIDATION_ERROR", message: "Invalid body", details: parsed.error.issues } },
      { status: 400 }
    );
  }

  // parsed.data is fully typed and safe to use
  const post = await createPost(parsed.data);
  return Response.json(post, { status: 201 });
}

6. Idempotency for Mutations

For operations like payment or order creation, accept an Idempotency-Key header. If the same key is seen twice, return the cached result instead of processing again:

const idempotencyKey = req.headers.get("Idempotency-Key");

if (idempotencyKey) {
  const cached = await cache.get(`idem:${idempotencyKey}`);
  if (cached) return Response.json(cached);
}

const result = await processPayment(data);

if (idempotencyKey) {
  await cache.set(`idem:${idempotencyKey}`, result, { ttl: 86400 });
}

7. Rate Limiting

Protect your API from abuse and accidental DDoS. A simple token bucket in Redis:

import { Ratelimit } from "@upstash/ratelimit";

const ratelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 req/min
});

const { success, limit, remaining } = await ratelimit.limit(userId);

if (!success) {
  return Response.json(
    { error: { code: "RATE_LIMITED", message: "Too many requests" } },
    {
      status: 429,
      headers: {
        "X-RateLimit-Limit": limit.toString(),
        "X-RateLimit-Remaining": remaining.toString(),
      },
    }
  );
}

8. OpenAPI Spec

Document your API with an OpenAPI spec. Tools like zod-to-openapi let you derive the spec from your existing Zod schemas — zero duplication:

import { generateOpenApi } from "@ts-rest/open-api";

const openApiDocument = generateOpenApi(router, {
  info: { title: "My API", version: "1.0.0" },
});

This gives you automatic Swagger UI, type-safe client generation, and contract testing for free.

Closing Thought

Good API design is an act of empathy — you're designing an interface for future-you and your teammates. Consistency, predictability, and clear error messages go further than any clever optimisation.