Designing a REST API from Scratch

API Design Intermediate 25 min Jan 12, 2026

Audience

This guide is for developers who want to design REST APIs that are intuitive, consistent, and maintainable:

  • Backend developers building APIs from scratch and wanting to follow best practices
  • Tech leads establishing API design standards for their teams
  • Frontend developers who want to understand what makes an API well-designed
  • Anyone who has struggled with inconsistent APIs and wants to do better

You should already understand HTTP methods and status codes. If not, start with HTTP for REST APIs.

Goal

After reading this guide, you’ll be able to:

  • Design URLs that follow REST conventions and are intuitive to use
  • Model resources and their relationships properly
  • Implement pagination, filtering, and sorting that scales
  • Create consistent error responses that help developers debug issues
  • Apply basic versioning to future-proof your API
  • Make informed design decisions backed by clear reasoning

This guide builds practical skillsβ€”you’ll be able to design real APIs after reading it.

1. The Foundation: Resource-Oriented Thinking

Before writing any code, you need to shift your mindset from “endpoints that do things” to “resources that exist.”

Think Nouns, Not Verbs

The most common API design mistake is creating action-oriented endpoints:

# BAD: Action-oriented (RPC-style)
POST /createUser
POST /getUserById
POST /updateUserEmail
POST /deleteUser
POST /sendPasswordReset

These endpoints are verbs. They describe what you do, not what exists. This approach leads to inconsistent, unpredictable APIs.

Instead, think about resourcesβ€”the “things” in your system:

# GOOD: Resource-oriented (REST-style)
POST   /users              # Create a user
GET    /users/123          # Get user 123
PATCH  /users/123          # Update user 123
DELETE /users/123          # Delete user 123
POST   /users/123/password-reset   # Trigger password reset

HTTP methods already convey the action. Your URLs should identify what is being acted upon.

Identifying Resources

Resources are typically:

  • Business entities: Users, products, orders, invoices
  • Relationships: Memberships, friendships, subscriptions
  • Actions (when necessary): Password resets, email verifications

Ask yourself: “What are the things in my system that have identity and can be manipulated?”

graph TD
    A[Your Domain] --> B[Business Entities]
    A --> C[Relationships]
    A --> D[Actions as Resources]
    B --> B1["users"]
    B --> B2["products"]
    B --> B3["orders"]
    C --> C1["memberships"]
    C --> C2["subscriptions"]
    D --> D1["password-resets"]
    D --> D2["email-verifications"]
    style A fill:#e3f2fd
    style B fill:#fff3e0
    style C fill:#fff3e0
    style D fill:#fff3e0

2. URL Design: The Art of Naming

URLs are the interface of your API. They should be predictable, readable, and consistent.

Use Plural Nouns

Collections should use plural nouns:

# GOOD: Plural nouns
GET /users
GET /products
GET /orders

# BAD: Singular nouns (inconsistent)
GET /user
GET /product
GET /order

Even for singleton resources, consider the collection pattern:

# Singleton in a collection context
GET /users/me          # Current user
GET /settings/current  # Current settings

Hierarchy and Nesting

Resources often have relationships. Express these through URL hierarchy:

# User's orders
GET /users/123/orders

# Specific order from a user
GET /users/123/orders/456

# Products in an order
GET /users/123/orders/456/products

But don’t nest too deep. More than 2-3 levels becomes unwieldy:

# TOO DEEP: Hard to read and use
GET /companies/1/departments/2/teams/3/members/4/tasks/5

# BETTER: Flatten when possible
GET /tasks/5
GET /tasks?team_id=3

Rule of thumb: If a resource has a global identifier, it can be accessed directly.

Naming Conventions

ConventionExampleUse When
Lowercase/users, /productsAlways
Hyphens for multi-word/order-items, /password-resetsAlways
Plural for collections/users, /ordersFor collections
No trailing slashes/users not /users/Always
No file extensions/users not /users.jsonAlways
No verbs in URLs/users not /getUsersAlways (use HTTP methods)

URL Design Decision Tree

flowchart TD
    A[Design a new endpoint] --> B{Is it a collection
of things?} B -->|Yes| C[Use plural noun:
/resources] B -->|No| D{Is it a specific
item?} D -->|Yes| E[Use collection + ID:
/resources/123] D -->|No| F{Is it nested
under another resource?} F -->|Yes| G{Depth > 2 levels?} F -->|No| H[Create as top-level:
/resources] G -->|Yes| I[Flatten it:
/child-resources?parent_id=X] G -->|No| J[Nest it:
/parents/X/children] style C fill:#c8e6c9 style E fill:#c8e6c9 style H fill:#c8e6c9 style I fill:#c8e6c9 style J fill:#c8e6c9

3. Collections and Items: Two Patterns

Every resource type typically exposes two patterns: the collection and the item.

Collection Operations

Collections represent groups of resources:

# List all users (collection)
GET /users
β†’ Returns: Array of users

# Create a new user (add to collection)
POST /users
β†’ Body: New user data
β†’ Returns: Created user with ID

# Bulk operations (less common)
DELETE /users?status=inactive
β†’ Deletes multiple users matching criteria

Item Operations

Items represent individual resources:

# Get a specific user (item)
GET /users/123
β†’ Returns: Single user object

# Update a specific user (partial)
PATCH /users/123
β†’ Body: Fields to update
β†’ Returns: Updated user

# Replace a user (full)
PUT /users/123
β†’ Body: Complete user object
β†’ Returns: Replaced user

# Delete a specific user
DELETE /users/123
β†’ Returns: 204 No Content

The Complete CRUD Pattern

OperationHTTP MethodURLRequest BodyResponse
List allGET/users-Array of users
Get oneGET/users/123-Single user
CreatePOST/usersNew userCreated user + Location header
Update partialPATCH/users/123Changed fieldsUpdated user
Replace fullPUT/users/123Complete userReplaced user
DeleteDELETE/users/123-204 No Content

4. Pagination: Handling Large Collections

Real-world collections can have millions of items. You need pagination.

Three Pagination Strategies

graph LR
    A[Pagination Strategies] --> B[Offset-based]
    A --> C[Cursor-based]
    A --> D[Keyset-based]
    B --> B1["Simple, random access"]
    B --> B2["Slow on large datasets"]
    C --> C1["Fast, stable"]
    C --> C2["No random access"]
    D --> D1["Fast, deterministic"]
    D --> D2["Requires sorted field"]
    style B fill:#fff9c4
    style C fill:#c8e6c9
    style D fill:#c8e6c9

Offset-Based Pagination

The simplest approach. Specify offset (skip) and limit (take):

GET /users?offset=0&limit=20   # Page 1
GET /users?offset=20&limit=20  # Page 2
GET /users?offset=40&limit=20  # Page 3

Response with pagination metadata:

{
  "data": [
    {"id": 1, "name": "Alice"},
    {"id": 2, "name": "Bob"}
  ],
  "pagination": {
    "offset": 0,
    "limit": 20,
    "total": 1547
  }
}

Pros:

  • Simple to implement
  • Supports random access (jump to page 50)
  • Easy to understand

Cons:

  • Slow on large datasets (database must count offset rows)
  • Inconsistent results when data changes between requests
  • Performance degrades: OFFSET 1000000 is expensive

Use when: Small datasets (< 10,000 items), random access needed.

Cursor-Based Pagination

Uses an opaque cursor (encoded position marker):

GET /users?limit=20
β†’ Returns: data + cursor

GET /users?limit=20&cursor=eyJpZCI6MjB9
β†’ Returns: next page + new cursor

Response:

{
  "data": [
    {"id": 21, "name": "Carol"},
    {"id": 22, "name": "Dave"}
  ],
  "pagination": {
    "cursor": "eyJpZCI6NDB9",
    "has_more": true
  }
}

Pros:

  • Fast on large datasets (no offset counting)
  • Stable results (won’t skip/duplicate on data changes)
  • Works well with real-time data

Cons:

  • No random access (must traverse sequentially)
  • Cursor can become invalid if referenced item is deleted
  • More complex to implement

Use when: Large datasets, infinite scroll UIs, real-time feeds.

Keyset-Based Pagination

Uses the last seen value of a sorted field:

GET /users?limit=20&sort=created_at
β†’ Returns: users sorted by created_at

GET /users?limit=20&sort=created_at&after=2026-01-01T12:00:00Z
β†’ Returns: users created after that timestamp

Response:

{
  "data": [
    {"id": 45, "name": "Eve", "created_at": "2026-01-01T12:00:01Z"},
    {"id": 46, "name": "Frank", "created_at": "2026-01-01T12:00:02Z"}
  ],
  "pagination": {
    "after": "2026-01-01T12:00:20Z",
    "has_more": true
  }
}

Pros:

  • Very fast (uses database indexes directly)
  • Deterministic results
  • No counting required

Cons:

  • Requires a unique, sortable field
  • No random access
  • Complex for multi-column sorts

Use when: Large datasets sorted by timestamp or ID.

Pagination Response Format

Always include pagination metadata:

{
  "data": [...],
  "pagination": {
    "total": 1547,
    "limit": 20,
    "offset": 40,
    "has_more": true,
    "links": {
      "self": "/users?offset=40&limit=20",
      "first": "/users?offset=0&limit=20",
      "prev": "/users?offset=20&limit=20",
      "next": "/users?offset=60&limit=20",
      "last": "/users?offset=1540&limit=20"
    }
  }
}

5. Filtering: Finding What You Need

Collections need filtering to be useful.

Query Parameter Syntax

The most common approach uses query parameters:

# Simple equality filters
GET /users?status=active
GET /users?role=admin
GET /users?status=active&role=admin  # AND logic

# Multiple values (OR logic)
GET /users?status=active,inactive
GET /users?role=admin&role=editor    # Multiple params

# Nested field filtering
GET /users?address.city=London

Operators for Complex Filters

For comparisons beyond equality, use operator suffixes or special syntax:

# Approach 1: Operator suffixes
GET /products?price_gte=100           # price >= 100
GET /products?price_lte=500           # price <= 500
GET /products?created_at_gt=2026-01-01  # created after date

# Approach 2: Bracket notation
GET /products?price[gte]=100
GET /products?price[lte]=500
GET /products?created_at[gt]=2026-01-01

# Approach 3: Special operators
GET /products?filter=price:gte:100
GET /products?filter=price:between:100,500

Common Filter Operators

OperatorMeaningExample
eq (default)Equals?status=active
neNot equals?status_ne=deleted
gtGreater than?price_gt=100
gteGreater than or equal?price_gte=100
ltLess than?price_lt=500
lteLess than or equal?price_lte=500
inIn list?status_in=active,pending
containsString contains?name_contains=john
startsString starts with?name_starts=john

Search vs. Filter

Distinguish between structured filtering and full-text search:

# Structured filter (exact field matching)
GET /products?category=electronics&brand=Sony

# Full-text search (searches across multiple fields)
GET /products?q=wireless+headphones
GET /products?search=wireless+headphones

6. Sorting: Ordering Results

Sorting controls the order of collection results.

Single-Field Sorting

# Sort by single field
GET /users?sort=name           # Ascending (default)
GET /users?sort=name:asc       # Explicit ascending
GET /users?sort=name:desc      # Descending
GET /users?sort=-name          # Prefix notation: - means desc

Multi-Field Sorting

# Multiple sort fields (comma-separated)
GET /users?sort=role:asc,name:asc
GET /users?sort=role,-created_at    # role asc, then created_at desc

Sort Response Headers

Include sort info in the response:

{
  "data": [...],
  "sorting": {
    "fields": [
      {"field": "role", "direction": "asc"},
      {"field": "created_at", "direction": "desc"}
    ]
  }
}

Default Sorting

Always define a default sort order for consistency:

# If no sort specified
GET /users
β†’ Defaults to: sort=created_at:desc (newest first)

# Or: sort=id:asc (stable order)

7. Error Responses: Helping Developers Debug

Good error responses make your API easier to integrate with.

Consistent Error Structure

Every error response should follow the same structure:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "The request contains invalid fields",
    "details": [
      {
        "field": "email",
        "code": "INVALID_FORMAT",
        "message": "Email must be a valid email address"
      },
      {
        "field": "age",
        "code": "OUT_OF_RANGE",
        "message": "Age must be between 18 and 120"
      }
    ],
    "request_id": "req_abc123xyz"
  }
}

Error Response Fields

FieldPurposeExample
codeMachine-readable error type"VALIDATION_ERROR", "NOT_FOUND"
messageHuman-readable explanation"User not found"
detailsField-level errors (for validation)Array of field errors
request_idFor debugging/support"req_abc123xyz"
documentation_urlLink to help docs (optional)"https://api.example.com/docs/errors/RATE_LIMITED"

Error Code Categories

Define a consistent set of error codes:

# Authentication/Authorization
AUTHENTICATION_REQUIRED    β†’ 401
INVALID_CREDENTIALS       β†’ 401
TOKEN_EXPIRED            β†’ 401
PERMISSION_DENIED        β†’ 403

# Resource errors
NOT_FOUND               β†’ 404
ALREADY_EXISTS          β†’ 409
CONFLICT                β†’ 409

# Validation errors
VALIDATION_ERROR        β†’ 400
INVALID_FORMAT          β†’ 400
MISSING_FIELD           β†’ 400
OUT_OF_RANGE            β†’ 400

# Rate limiting
RATE_LIMITED            β†’ 429

# Server errors
INTERNAL_ERROR          β†’ 500
SERVICE_UNAVAILABLE     β†’ 503

Error Decision Tree

flowchart TD
    A[Error occurred] --> B{Authentication
issue?} B -->|Yes| C{Token present?} B -->|No| D{Resource
exists?} C -->|No| E["401 + AUTHENTICATION_REQUIRED"] C -->|Yes| F{Token valid?} F -->|No| G["401 + INVALID_CREDENTIALS"] F -->|Yes| H["403 + PERMISSION_DENIED"] D -->|No| I["404 + NOT_FOUND"] D -->|Yes| J{Request
valid?} J -->|No| K["400/422 + VALIDATION_ERROR"] J -->|Yes| L{Business rule
violation?} L -->|Yes| M["409 + CONFLICT or custom"] L -->|No| N["500 + INTERNAL_ERROR"] style E fill:#ffccbc style G fill:#ffccbc style H fill:#ffccbc style I fill:#ffccbc style K fill:#fff9c4 style M fill:#fff9c4 style N fill:#ffcdd2

Validation Error Example

For form/input validation, provide detailed field-level errors:

POST /users
Content-Type: application/json

{
  "name": "",
  "email": "not-an-email",
  "age": -5
}
HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "The request body contains invalid fields",
    "details": [
      {
        "field": "name",
        "code": "REQUIRED",
        "message": "Name is required"
      },
      {
        "field": "email",
        "code": "INVALID_FORMAT",
        "message": "Email must be a valid email address"
      },
      {
        "field": "age",
        "code": "OUT_OF_RANGE",
        "message": "Age must be a positive number"
      }
    ]
  }
}

8. Field Selection: Partial Responses

Let clients request only the fields they need.

Sparse Fieldsets

# Return only specific fields
GET /users/123?fields=id,name,email

# Response
{
  "id": 123,
  "name": "Alice",
  "email": "[email protected]"
}

# Compare to full response
GET /users/123

{
  "id": 123,
  "name": "Alice",
  "email": "[email protected]",
  "phone": "+1-555-1234",
  "address": {...},
  "preferences": {...},
  "created_at": "2026-01-01T00:00:00Z",
  "updated_at": "2026-01-10T12:00:00Z"
}

Nested Field Selection

For related resources:

# Select fields on nested objects
GET /users/123?fields=id,name,address.city,address.country

# Response
{
  "id": 123,
  "name": "Alice",
  "address": {
    "city": "London",
    "country": "UK"
  }
}

Benefits of Field Selection

  • Reduced bandwidth: Send only what’s needed
  • Faster responses: Less data to serialize
  • Mobile-friendly: Critical for limited bandwidth
  • Privacy: Don’t expose fields client doesn’t need

9. API Versioning: Planning for Change

APIs evolve. Versioning helps you make changes without breaking existing clients.

Why Version?

Without versioning:

graph LR
    A[API Change] --> B[All Clients Break]
    B --> C[Angry Users]
    B --> D[Support Tickets]
    B --> E[Lost Trust]
    style A fill:#ffccbc
    style B fill:#ffcdd2

With versioning:

graph LR
    A[API v2 Released] --> B[v1 Still Works]
    A --> C[Clients Migrate
at Their Pace] B --> D[No Breaking Changes] C --> D D --> E[Happy Users] style A fill:#c8e6c9 style D fill:#c8e6c9 style E fill:#c8e6c9

The simplest and most visible approach:

# Version in URL path
GET /v1/users
GET /v2/users

# Full URL
https://api.example.com/v1/users
https://api.example.com/v2/users

Pros:

  • Highly visible and explicit
  • Easy to test and debug
  • Works with all HTTP tools
  • Clear in documentation

Cons:

  • Changes all URLs when version bumps
  • Can lead to code duplication

This is the default choice for most APIs. Start here.

Header Versioning (Alternative)

Version specified in request header:

GET /users
Accept: application/vnd.example.v1+json

GET /users
Accept: application/vnd.example.v2+json

Or custom header:

GET /users
X-API-Version: 1

GET /users
X-API-Version: 2

Pros:

  • Clean URLs
  • Same URL for all versions
  • Follows HTTP content negotiation

Cons:

  • Less visible (not in URL)
  • Harder to test in browser
  • Easy to forget the header
GET /users?version=1
GET /users?version=2

Avoid this approach. It mixes versioning with query semantics and can cause caching issues.

When to Create a New Version

Create a new version for breaking changes:

  • Removing a field
  • Renaming a field
  • Changing a field’s type
  • Changing required/optional status
  • Changing error response format
  • Changing authentication scheme

Non-breaking changes don’t require a new version:

  • Adding new fields (additive)
  • Adding new endpoints
  • Adding new optional parameters
  • Adding new error codes

Version Lifecycle

v1 (current) β†’ v2 (released) β†’ v1 (deprecated) β†’ v1 (sunset)
                                    ↓
                              6-12 months warning
                                    ↓
                              v1 removed

Always announce deprecation in advance (6-12 months minimum for production APIs).

10. Putting It All Together: E-Commerce API Example

Let’s design a complete e-commerce API following all the principles.

Resource Hierarchy

graph TD
    A[API Root] --> B[/users]
    A --> C[/products]
    A --> D[/orders]
    A --> E[/categories]

    B --> B1[/users/:id]
    B1 --> B2[/users/:id/orders]
    B1 --> B3[/users/:id/addresses]

    C --> C1[/products/:id]
    C1 --> C2[/products/:id/reviews]

    D --> D1[/orders/:id]
    D1 --> D2[/orders/:id/items]

    E --> E1[/categories/:id]
    E1 --> E2[/categories/:id/products]

    style A fill:#e3f2fd

Complete Endpoint Design

Users:

GET    /v1/users                    # List users (admin)
POST   /v1/users                    # Create user (signup)
GET    /v1/users/me                 # Get current user
PATCH  /v1/users/me                 # Update current user
GET    /v1/users/:id                # Get user by ID (admin)
GET    /v1/users/:id/orders         # Get user's orders
GET    /v1/users/:id/addresses      # Get user's addresses
POST   /v1/users/:id/addresses      # Add address

Products:

GET    /v1/products                 # List products
GET    /v1/products/:id             # Get product
POST   /v1/products                 # Create product (admin)
PATCH  /v1/products/:id             # Update product (admin)
DELETE /v1/products/:id             # Delete product (admin)
GET    /v1/products/:id/reviews     # Get product reviews
POST   /v1/products/:id/reviews     # Add review

Orders:

GET    /v1/orders                   # List orders (user's own or admin)
POST   /v1/orders                   # Create order
GET    /v1/orders/:id               # Get order
PATCH  /v1/orders/:id               # Update order (status changes)
DELETE /v1/orders/:id               # Cancel order
GET    /v1/orders/:id/items         # Get order items

Categories:

GET    /v1/categories               # List categories
GET    /v1/categories/:id           # Get category
GET    /v1/categories/:id/products  # Get products in category

Example: List Products with Filters

GET /v1/products?category=electronics&price_gte=100&price_lte=500&sort=-rating&limit=20&offset=0

HTTP/1.1 200 OK
Content-Type: application/json

{
  "data": [
    {
      "id": "prod_123",
      "name": "Wireless Headphones",
      "category": "electronics",
      "price": 299.99,
      "rating": 4.8,
      "in_stock": true
    },
    {
      "id": "prod_456",
      "name": "Bluetooth Speaker",
      "category": "electronics",
      "price": 149.99,
      "rating": 4.6,
      "in_stock": true
    }
  ],
  "pagination": {
    "offset": 0,
    "limit": 20,
    "total": 156,
    "has_more": true
  },
  "filters_applied": {
    "category": "electronics",
    "price_gte": 100,
    "price_lte": 500
  },
  "sorting": {
    "field": "rating",
    "direction": "desc"
  }
}

Example: Create Order with Error

POST /v1/orders
Content-Type: application/json
Authorization: Bearer eyJhbG...

{
  "items": [
    {"product_id": "prod_123", "quantity": 0},
    {"product_id": "prod_999", "quantity": 2}
  ],
  "shipping_address_id": "addr_456"
}

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Order could not be created due to validation errors",
    "details": [
      {
        "field": "items[0].quantity",
        "code": "OUT_OF_RANGE",
        "message": "Quantity must be at least 1"
      },
      {
        "field": "items[1].product_id",
        "code": "NOT_FOUND",
        "message": "Product 'prod_999' does not exist"
      }
    ],
    "request_id": "req_xyz789"
  }
}

What’s Next

This guide covered the fundamentals of REST API designβ€”enough to build a well-structured API.

For deeper topics like:

  • API evolution without breaking clients
  • Versioning strategies for live APIs
  • Migration patterns for existing APIs
  • Long-term backward compatibility
  • Deprecation and sunset policies

See the upcoming course: REST API Design That Doesn’t Break.


Deepen your understanding: