System Designintermediate

API Design Best Practices

Complete guide to designing production-ready APIs. Covers REST principles, GraphQL trade-offs, versioning strategies, pagination, error handling, rate limiting, and documentation.

13 min readΒ·Published Apr 29, 2026
system-designapirestgraphql

APIs Are Contracts

An API is a contract between your service and its consumers. Once published, changing it breaks people. A well-designed API is intuitive, consistent, and evolves gracefully. A poorly designed API generates support tickets, confuses developers, and creates backward compatibility nightmares.

This article covers RESTful design principles, the GraphQL alternative, versioning strategies, pagination patterns, error handling, rate limiting, and API documentation β€” everything you need to design APIs that developers enjoy using.

REST Principles

REST (Representational State Transfer) is an architectural style, not a specification. Most "REST APIs" are actually HTTP APIs that follow some REST conventions. Here are the principles that matter in practice.

Resources and URLs

Resources are nouns. URLs identify resources. HTTP methods define actions on those resources.

Good (resource-oriented):
GET    /api/users              β€” List users
GET    /api/users/123          β€” Get specific user
POST   /api/users              β€” Create user
PUT    /api/users/123          β€” Replace user entirely
PATCH  /api/users/123          β€” Update user partially
DELETE /api/users/123          β€” Delete user

Bad (action-oriented):
GET    /api/getUsers
POST   /api/createUser
POST   /api/deleteUser/123
GET    /api/getUserOrders?userId=123

Nested resources for clear relationships:

GET    /api/users/123/orders          β€” Orders belonging to user 123
GET    /api/users/123/orders/456      β€” Specific order for user 123
POST   /api/users/123/orders          β€” Create order for user 123

GET    /api/orders/456/items          β€” Items in order 456
GET    /api/orders/456/items/789      β€” Specific item in order 456

Guideline: Nest at most 2 levels deep. Beyond that, use a top-level resource with a filter.

Bad:  /api/users/123/orders/456/items/789/reviews
Good: /api/reviews?itemId=789

HTTP Methods

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Method  β”‚ Purpose      β”‚ Idempotent? β”‚ Request Body β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ GET     β”‚ Read         β”‚ Yes         β”‚ No           β”‚
β”‚ POST    β”‚ Create       β”‚ No          β”‚ Yes          β”‚
β”‚ PUT     β”‚ Replace      β”‚ Yes         β”‚ Yes          β”‚
β”‚ PATCH   β”‚ Partial      β”‚ No*         β”‚ Yes          β”‚
β”‚ DELETE  β”‚ Remove       β”‚ Yes         β”‚ No (usually) β”‚
β”‚ HEAD    β”‚ Headers only β”‚ Yes         β”‚ No           β”‚
β”‚ OPTIONS β”‚ CORS/methods β”‚ Yes         β”‚ No           β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

* PATCH can be made idempotent depending on implementation

Idempotency matters. An idempotent operation produces the same result whether called once or multiple times. This is critical for retry logic β€” if a network timeout occurs, the client can safely retry a PUT or DELETE without side effects.

HTTP Status Codes

Use the right status code. Do not return 200 for everything.

2xx Success:
200 OK                  β€” General success (GET, PUT, PATCH, DELETE)
201 Created             β€” Resource created (POST)
204 No Content          β€” Success with no response body (DELETE)

3xx Redirection:
301 Moved Permanently   β€” Resource URL changed permanently
304 Not Modified        β€” Client cache is still valid

4xx Client Error:
400 Bad Request         β€” Invalid input / validation error
401 Unauthorized        β€” Missing or invalid authentication
403 Forbidden           β€” Authenticated but not authorized
404 Not Found           β€” Resource does not exist
405 Method Not Allowed  β€” HTTP method not supported for this resource
409 Conflict            β€” Resource state conflict (duplicate, version mismatch)
422 Unprocessable Entityβ€” Syntactically valid but semantically wrong
429 Too Many Requests   β€” Rate limit exceeded

5xx Server Error:
500 Internal Server Error β€” Unexpected server failure
502 Bad Gateway          β€” Upstream service unavailable
503 Service Unavailable  β€” Server temporarily overloaded
504 Gateway Timeout      β€” Upstream service timeout

Implementation Example

import express from 'express';

const router = express.Router();

// GET /api/users β€” List users with pagination
router.get('/users', async (req, res) => {
  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 [users, total] = await Promise.all([
    db.users.findMany({ skip: offset, take: limit }),
    db.users.count(),
  ]);

  res.json({
    data: users,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit),
    },
  });
});

// GET /api/users/:id β€” Get specific user
router.get('/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  if (!user) {
    return res.status(404).json({
      error: {
        code: 'USER_NOT_FOUND',
        message: `User with id ${req.params.id} not found`,
      },
    });
  }
  res.json({ data: user });
});

// POST /api/users β€” Create user
router.post('/users', async (req, res) => {
  const { email, name } = req.body;

  // Validation
  if (!email || !name) {
    return res.status(400).json({
      error: {
        code: 'VALIDATION_ERROR',
        message: 'Missing required fields',
        details: [
          ...(!email ? [{ field: 'email', message: 'Email is required' }] : []),
          ...(!name ? [{ field: 'name', message: 'Name is required' }] : []),
        ],
      },
    });
  }

  try {
    const user = await db.users.create({ email, name });
    res.status(201).json({ data: user });
  } catch (error) {
    if (error.code === 'UNIQUE_VIOLATION') {
      return res.status(409).json({
        error: {
          code: 'DUPLICATE_EMAIL',
          message: 'A user with this email already exists',
        },
      });
    }
    throw error;
  }
});

// PATCH /api/users/:id β€” Partial update
router.patch('/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  if (!user) {
    return res.status(404).json({
      error: { code: 'USER_NOT_FOUND', message: 'User not found' },
    });
  }

  const updated = await db.users.update(req.params.id, req.body);
  res.json({ data: updated });
});

// DELETE /api/users/:id β€” Delete user
router.delete('/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  if (!user) {
    return res.status(404).json({
      error: { code: 'USER_NOT_FOUND', message: 'User not found' },
    });
  }

  await db.users.delete(req.params.id);
  res.status(204).send();
});

GraphQL vs REST

GraphQL is not a replacement for REST. It solves different problems and introduces different trade-offs.

REST β€” multiple endpoints, fixed response shape:
GET /api/users/123           β†’ { id, name, email, address, phone, ... }
GET /api/users/123/orders    β†’ [{ id, total, status, items, ... }]
GET /api/users/123/reviews   β†’ [{ id, rating, text, product, ... }]
= 3 HTTP requests, potentially over-fetching fields you do not need

GraphQL β€” single endpoint, client specifies response shape:
POST /graphql
{
  user(id: "123") {
    name
    email
    orders(last: 5) {
      id
      total
      status
    }
  }
}
= 1 HTTP request, only the fields you need

GraphQL Schema Example

type User {
  id: ID!
  name: String!
  email: String!
  orders(first: Int, after: String): OrderConnection!
  createdAt: DateTime!
}

type Order {
  id: ID!
  total: Float!
  status: OrderStatus!
  items: [OrderItem!]!
  createdAt: DateTime!
}

type OrderItem {
  id: ID!
  product: Product!
  quantity: Int!
  unitPrice: Float!
}

enum OrderStatus {
  PENDING
  CONFIRMED
  SHIPPED
  DELIVERED
  CANCELLED
}

type OrderConnection {
  edges: [OrderEdge!]!
  pageInfo: PageInfo!
}

type OrderEdge {
  node: Order!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}

type Query {
  user(id: ID!): User
  users(first: Int, after: String): UserConnection!
  order(id: ID!): Order
}

type Mutation {
  createUser(input: CreateUserInput!): User!
  updateUser(id: ID!, input: UpdateUserInput!): User!
  createOrder(input: CreateOrderInput!): Order!
}

Comparison Table

AspectRESTGraphQL
EndpointsMultiple (per resource)Single (/graphql)
Data fetchingOver/under fetching commonClient gets exactly what it asks for
CachingHTTP caching (simple)Complex (query-level caching)
Error handlingHTTP status codesAlways 200, errors in response body
File uploadsStraightforward (multipart)Requires workarounds
Real-timeWebSocket, SSESubscriptions (built-in)
ToolingMature (Postman, curl, OpenAPI)Growing (Apollo, Relay, GraphiQL)
Learning curveLowerHigher
N+1 problemManaged by serverMust use DataLoader or similar
Best forSimple CRUD, public APIs, microservicesComplex UIs, mobile apps, multiple client types

When to Use Each

Choose REST when:

  • Your API is simple CRUD operations
  • You have a public API consumed by external developers
  • HTTP caching is important for performance
  • Your team is not experienced with GraphQL

Choose GraphQL when:

  • Multiple clients (web, mobile, TV) need different data shapes
  • You have deeply nested, related data
  • Under-fetching or over-fetching is a real performance problem
  • You want a strongly-typed, self-documenting API

API Versioning

Your API will change. How you handle those changes determines whether your consumers are happy or furious.

URL Path Versioning (Most Common)

GET /api/v1/users/123
GET /api/v2/users/123
// Express route organization
import v1Router from './routes/v1';
import v2Router from './routes/v2';

app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);

Pros: Explicit, easy to understand, easy to route. Cons: URL pollution, entire API versioned even if only one endpoint changed.

Header Versioning

GET /api/users/123
Accept: application/vnd.myapi.v2+json
app.get('/api/users/:id', (req, res) => {
  const version = req.headers['accept']?.match(/vnd\.myapi\.v(\d+)/)?.[1] || '1';

  if (version === '2') {
    return res.json(formatUserV2(user));
  }
  return res.json(formatUserV1(user));
});

Pros: Clean URLs, version only what changes. Cons: Harder to test (cannot just change URL), less discoverable.

Query Parameter Versioning

GET /api/users/123?version=2

Pros: Simple. Cons: Easy to forget, mixes versioning with other query params.

Versioning Strategy

Recommended approach:
1. Use URL path versioning (/api/v1/)
2. Increment major version only for breaking changes
3. Support at most 2 versions simultaneously
4. Deprecate old versions with a sunset date
5. Communicate changes via changelog and migration guide
// Deprecation header
app.use('/api/v1', (req, res, next) => {
  res.set('Deprecation', 'true');
  res.set('Sunset', 'Sat, 01 Nov 2026 00:00:00 GMT');
  res.set('Link', '</api/v2>; rel="successor-version"');
  next();
}, v1Router);

Pagination

Never return unbounded lists. Always paginate.

Offset-Based Pagination

GET /api/users?page=3&limit=20

Response:
{
  "data": [...20 users...],
  "pagination": {
    "page": 3,
    "limit": 20,
    "total": 1543,
    "totalPages": 78
  }
}

Pros: Simple, supports "jump to page 50". Cons: Slow on large datasets (OFFSET scans rows), inconsistent if data changes between pages.

Cursor-Based Pagination

GET /api/users?limit=20&after=eyJpZCI6MTAwfQ==

Response:
{
  "data": [...20 users...],
  "pagination": {
    "hasNextPage": true,
    "hasPreviousPage": true,
    "startCursor": "eyJpZCI6MTAxfQ==",
    "endCursor": "eyJpZCI6MTIwfQ=="
  }
}
// Cursor-based pagination implementation
router.get('/users', async (req, res) => {
  const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
  const after = req.query.after as string | undefined;

  let query = db('users').orderBy('id', 'asc').limit(limit + 1); // +1 to check hasNextPage

  if (after) {
    const cursor = JSON.parse(Buffer.from(after, 'base64').toString());
    query = query.where('id', '>', cursor.id);
  }

  const rows = await query;
  const hasNextPage = rows.length > limit;
  const data = hasNextPage ? rows.slice(0, -1) : rows;

  res.json({
    data,
    pagination: {
      hasNextPage,
      endCursor: data.length > 0
        ? Buffer.from(JSON.stringify({ id: data[data.length - 1].id })).toString('base64')
        : null,
    },
  });
});

Pros: Consistent results even with data changes, performant on large datasets (uses index). Cons: Cannot jump to arbitrary page, slightly more complex.

Which Pagination to Use

Use CaseRecommended
Admin dashboard, tables with "page 1, 2, 3"Offset-based
Infinite scroll, mobile feedsCursor-based
Real-time data (new items added frequently)Cursor-based
Small datasets (< 10,000 rows)Either works
Large datasets (> 100,000 rows)Cursor-based

Error Response Format

Consistent error responses make debugging easier for API consumers.

// Standard error response format
type ApiError = {
  error: {
    code: string;          // Machine-readable error code
    message: string;       // Human-readable message
    details?: {            // Field-level validation errors
      field: string;
      message: string;
    }[];
    requestId?: string;    // For support/debugging
  };
};

// Error handling middleware
function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
  const requestId = req.headers['x-request-id'] || generateRequestId();

  if (err instanceof ValidationError) {
    return res.status(400).json({
      error: {
        code: 'VALIDATION_ERROR',
        message: 'Request validation failed',
        details: err.errors.map(e => ({
          field: e.path,
          message: e.message,
        })),
        requestId,
      },
    });
  }

  if (err instanceof NotFoundError) {
    return res.status(404).json({
      error: {
        code: 'NOT_FOUND',
        message: err.message,
        requestId,
      },
    });
  }

  // Unexpected errors β€” log full details, return generic message
  logger.error('Unhandled error', {
    error: err.message,
    stack: err.stack,
    requestId,
    path: req.path,
    method: req.method,
  });

  res.status(500).json({
    error: {
      code: 'INTERNAL_ERROR',
      message: 'An unexpected error occurred',
      requestId,
    },
  });
}

app.use(errorHandler);

Rate Limiting

Protect your API from abuse and ensure fair usage.

Rate limit: 100 requests per minute per API key

Request  1: 200 OK          (remaining: 99)
Request  2: 200 OK          (remaining: 98)
...
Request 100: 200 OK          (remaining: 0)
Request 101: 429 Too Many Requests
             Retry-After: 35  (seconds until window resets)
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';

// Basic rate limiter
const apiLimiter = rateLimit({
  windowMs: 60 * 1000,     // 1 minute window
  max: 100,                 // 100 requests per window
  standardHeaders: true,    // Return rate limit info in headers
  legacyHeaders: false,

  // Use Redis for distributed rate limiting (multiple servers)
  store: new RedisStore({
    sendCommand: (...args) => redisClient.sendCommand(args),
  }),

  handler: (req, res) => {
    res.status(429).json({
      error: {
        code: 'RATE_LIMIT_EXCEEDED',
        message: 'Too many requests. Please try again later.',
        retryAfter: Math.ceil(res.getHeader('Retry-After') as number),
      },
    });
  },
});

app.use('/api/', apiLimiter);

// Tiered rate limits by plan
const tierLimits = {
  free: rateLimit({ windowMs: 60000, max: 30 }),
  pro: rateLimit({ windowMs: 60000, max: 300 }),
  enterprise: rateLimit({ windowMs: 60000, max: 3000 }),
};

app.use('/api/', (req, res, next) => {
  const tier = req.user?.tier || 'free';
  tierLimits[tier](req, res, next);
});

Rate limit headers (RFC standard):

HTTP/1.1 200 OK
RateLimit-Limit: 100
RateLimit-Remaining: 42
RateLimit-Reset: 1714060800

API Documentation (OpenAPI/Swagger)

Document your API with OpenAPI (formerly Swagger) so consumers can explore it without reading source code.

# openapi.yaml
openapi: 3.0.3
info:
  title: User Management API
  version: 2.0.0
  description: API for managing users and orders

servers:
  - url: https://api.example.com/v2
    description: Production

paths:
  /users:
    get:
      summary: List users
      operationId: listUsers
      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"
                  pagination:
                    $ref: "#/components/schemas/Pagination"

    post:
      summary: Create user
      operationId: createUser
      tags: [Users]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateUserInput"
      responses:
        "201":
          description: User created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/User"
        "400":
          $ref: "#/components/responses/ValidationError"
        "409":
          $ref: "#/components/responses/ConflictError"

components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        email:
          type: string
          format: email
        createdAt:
          type: string
          format: date-time
      required: [id, name, email, createdAt]

    CreateUserInput:
      type: object
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
        email:
          type: string
          format: email
      required: [name, email]

    Pagination:
      type: object
      properties:
        page:
          type: integer
        limit:
          type: integer
        total:
          type: integer
        totalPages:
          type: integer

  responses:
    ValidationError:
      description: Validation failed
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: object
                properties:
                  code:
                    type: string
                    example: VALIDATION_ERROR
                  message:
                    type: string
                  details:
                    type: array
                    items:
                      type: object
                      properties:
                        field:
                          type: string
                        message:
                          type: string

Key Takeaways

  • Design around resources, not actions. Use HTTP methods for CRUD operations on noun-based URLs.
  • Use proper HTTP status codes. 200 is not the only status code. Clients depend on status codes for error handling and retry logic.
  • GraphQL solves the under/over-fetching problem but adds complexity. Use it when you have multiple client types with different data needs.
  • Version your API from day one with URL path versioning. Support at most two versions and deprecate with sunset headers.
  • Always paginate list endpoints. Use cursor-based pagination for large datasets and real-time data.
  • Standardize your error response format. Include a machine-readable code, human-readable message, and a request ID for debugging.
  • Rate limit every API. Even internal APIs need protection from runaway clients and bugs.
  • Document with OpenAPI. Auto-generate docs, client SDKs, and validation from a single source of truth.

Found this helpful?

Support devsofus β€” help us keep creating free dev guides.

Related Articles