Design a REST API
A step-by-step guide for designing a REST API that consumers actually enjoy using — from resource modeling to error responses. Focuses on the decisions that matter, not framework-specific code.
Overview
A well-designed REST API disappears — consumers use it without fighting it. A poorly designed one generates Slack messages, support tickets, and workarounds that live forever. This playbook walks you through the seven decisions that shape a clean, consistent API: from choosing your resources and naming your endpoints, to designing error responses and pagination. Each step includes concrete do/don't guidance drawn from real-world API reviews. Follow these steps before writing code and you'll avoid the redesigns that come from coding first and designing never.
When to use
- +When starting a new service and defining its API surface from scratch
- +When an existing API has grown inconsistent across endpoints and needs alignment
- +When onboarding a new team member who will be designing endpoints
- +When writing an API style guide for your organization
- +During an API design review — use the checklist as a review framework
When NOT to use
- -When you need a real-time bidirectional protocol — consider WebSockets or gRPC instead
- -When the consumer is a single, tightly-coupled frontend — a BFF or GraphQL may be simpler
- -As a replacement for an API specification — this guides design thinking, not contract documentation
- -When you're building an internal RPC-style service with no external consumers
Practice Checklist
Identify your resources
List the nouns in your domain — these become your API resources. A resource is a thing, not an action.
Design your URI structure
Map resources to paths using plural nouns, max 2 levels of nesting. Actions become HTTP methods, not URL segments.
Define request/response shapes
Design your JSON schemas with consistent naming (camelCase or snake_case — pick one), envelope structure, and field types.
Design your error responses
Use RFC 9457 Problem Details format. Map business errors to proper HTTP status codes. Include field-level validation errors.
Design pagination and filtering
Add cursor-based pagination to all list endpoints. Return _links for navigation. Support consistent filter and sort patterns.
Plan for evolution
Design fields as optional by default. Plan additive changes. Avoid anything that would force versioning.
Write the OpenAPI spec
Document every endpoint, schema, and error case in an OpenAPI 3.x spec before writing implementation code.
Practice Guidance
1. Identify Your Resources
What
List the core nouns in your domain — users, orders, products, invoices, tasks. Each noun is a candidate resource. A resource represents a thing your API manages, not an action it performs.
Why it matters
Getting resources wrong cascades into every endpoint. If you model 'sending an email' as a resource instead of an action on a resource, you'll end up with awkward verbs in URLs and methods that don't map to HTTP semantics.
Common gaps
Modeling actions as resources (POST /sendEmail). Treating database tables as the resource model 1:1 — the API should reflect the business domain, not the schema. Creating too many fine-grained resources instead of composing related data.
| Do this | Not this | Why |
|---|---|---|
| Model around business concepts: /orders, /invoices, /shipments | Mirror your database tables: /order_line_items, /order_status_history | The API serves consumers, not your ORM. Database structure changes shouldn't force API changes. |
| Use nouns for resources, HTTP methods for actions: POST /emails | Use verbs in URLs: POST /sendEmail, GET /getUsers | HTTP methods already express the action — adding verbs to URLs is redundant and breaks REST semantics. |
| Group related data into a single resource with nested objects | Create separate endpoints for every attribute: /users/123/name, /users/123/email | Consumers expect to fetch a resource in one call. Over-splitting forces multiple round trips. |
2. Design Your URI Structure
What
Map resources to URL paths using plural nouns. Use path parameters for identifiers. Limit nesting to 2 levels maximum. Express relationships through query parameters or sub-resource collections.
Why it matters
Consistent URIs make your API predictable. A consumer who sees /users and /orders should be able to guess that /products exists and behaves the same way. Inconsistent URIs force consumers to check documentation for every endpoint.
Common gaps
Deep nesting beyond 2 levels (/companies/123/departments/456/teams/789/members). Mixing singular and plural (/user vs /orders). Inconsistent ID formats across endpoints.
| Do this | Not this | Why |
|---|---|---|
| Use plural nouns consistently: /users, /orders, /products | Mix singular and plural: /user/123 but /orders | Consistency means consumers learn the pattern once. Mixed conventions force them to memorize exceptions. |
| Stop nesting at 2 levels: /users/123/orders | Nest deeper: /users/123/orders/456/items/789/variants | Deep nesting couples your URL structure to your data model. After 2 levels, promote the nested resource: /order-items/789. |
| Use kebab-case for multi-word resources: /order-items, /user-profiles | Use camelCase or snake_case in URLs: /orderItems, /order_items | URLs are case-insensitive in practice (DNS) and kebab-case is the web convention. Consistency with browser URLs reduces confusion. |
3. Define Request/Response Shapes
What
Design your JSON payloads with consistent conventions: pick a casing style (camelCase or snake_case) and use it everywhere. Wrap responses in a data envelope. Use ISO 8601 for dates. Return only the fields consumers need.
Why it matters
Inconsistent payloads are the #1 source of developer frustration with APIs. When userId is camelCase in one endpoint and user_id in another, every consumer has to handle both — or worse, they don't, and their integration breaks silently.
Common gaps
Mixing casing conventions across endpoints. Returning raw database rows with internal fields exposed (password_hash, internal_status_code). Not having a consistent envelope structure (sometimes {data: ...}, sometimes bare objects, sometimes arrays at root).
| Do this | Not this | Why |
|---|---|---|
| Pick one casing convention and enforce it with a linter: {"userId": "u_123", "createdAt": "2026-04-13T10:00:00Z"} | Mix conventions: {"userId": "u_123", "created_at": "2026-04-13", "OrderStatus": "active"} | A linting rule catches this automatically. Inconsistency in the contract leads to inconsistency in every consumer's codebase. |
| Wrap responses in {data: ...} for single items and {data: [...], meta: {...}} for lists | Return bare objects or arrays at the root | An envelope lets you add metadata (pagination, timestamps, request IDs) without breaking the response shape. Bare arrays can't be extended. |
| Use ISO 8601 with timezone for all dates: "2026-04-13T10:00:00Z" | Use Unix timestamps or ambiguous formats: 1681380000, "04/13/2026", "13 Apr 2026" | ISO 8601 is unambiguous, sortable as strings, and supported by every language's standard library. |
4. Design Your Error Responses
What
Use proper HTTP status codes (don't return 200 for errors). Adopt RFC 9457 Problem Details as your error format. Include a machine-readable error type, a human-readable message, and field-level validation details for 422 responses.
Why it matters
Error responses are the most-consumed part of your API — consumers spend more time handling errors than processing success responses. A good error response tells the consumer what went wrong, why, and what to do about it. A bad one says 'something failed' and leaves them guessing.
Common gaps
Returning 200 with {"error": "not found"} — this breaks every HTTP client's error handling. Using 500 for validation errors. Inconsistent error shapes across endpoints (sometimes {error: "..."}, sometimes {message: "..."}, sometimes {errors: [...]}).
| Do this | Not this | Why |
|---|---|---|
| Return proper status codes: 400 for bad input, 401 for unauthenticated, 403 for unauthorized, 404 for not found, 422 for validation failures | Return 200 with {"status": "error"} or 500 for everything that isn't a 200 | HTTP status codes are the first thing every client checks. Misusing them breaks error handling, monitoring, retry logic, and caching — all of which rely on status code semantics. |
| Use RFC 9457: {"type": "https://api.example.com/errors/validation", "title": "Validation Error", "status": 422, "detail": "2 fields failed validation", "errors": [...]} | Invent a custom format per endpoint: {"error": true, "msg": "bad"} | RFC 9457 is a standard with library support in every major language. Custom formats force every consumer to write custom parsing. |
| Include field-level detail for validation errors: {"field": "email", "message": "must be a valid email address", "code": "invalid_format"} | Return a single string: "Validation failed" | Forms need to display errors next to the right field. A single error string means the frontend can't tell the user which field to fix. |
5. Design Pagination and Filtering
What
Add cursor-based pagination to every list endpoint from day one. Return _links with next/prev/first URLs so consumers never build pagination URLs themselves. Support filtering via query parameters and sorting with a sort parameter using -prefix for descending.
Why it matters
Every list endpoint will eventually return too many items. If you ship without pagination, you'll need a breaking change to add it. If you use offset pagination, you'll hit performance problems at scale. Cursor pagination is stable, performant, and works from the start.
Common gaps
Launching without pagination ('we only have 50 items'). Using offset pagination that breaks when items are inserted. Not including total or navigation metadata. Inconsistent filter parameter patterns across endpoints.
| Do this | Not this | Why |
|---|---|---|
| Return _links for navigation: {"_links": {"next": {"href": "/api/tasks?page[after]=abc123"}, "prev": {"href": "/api/tasks?page[before]=abc123"}}} | Make the frontend construct pagination URLs from cursor values | The backend owns the pagination logic. _links let you change the cursor format, add parameters, or switch pagination strategies without touching the frontend. |
| Use cursor pagination by default: ?page[size]=20&page[after]=opaqueCursor | Default to offset pagination: ?page=3&pageSize=20 | Offset pagination skips rows in the database (slow at scale) and breaks when items are inserted mid-page. Cursors are stable and O(1). |
| Use a consistent filter pattern: ?filter[status]=active&filter[priority]=high&sort=-created_at | Invent different patterns per endpoint: ?status=active on /tasks but ?filterByPriority=high on /projects | A consistent filter pattern is learnable. Consumers apply it to any list endpoint without checking docs every time. |
6. Plan for Evolution
What
Design every field as optional on responses (consumers should handle missing fields). Make changes additive — new fields, new endpoints, new query parameters. Never rename, remove, or change the type of an existing field without a deprecation cycle.
Why it matters
Your API is a contract. Every field you return is a promise. Every change to an existing field is a potential breaking change for every consumer. Designing for evolution from day one means you'll rarely need versioning — and versioning is expensive to maintain.
Common gaps
Renaming a field for consistency and breaking consumers who depend on the old name. Adding a required field to a request body. Changing a field from string to object. Not communicating changes before they ship.
| Do this | Not this | Why |
|---|---|---|
| Add new fields without removing old ones. Deprecate with a Sunset header, then remove after the sunset date. | Rename a field in place: "userName" → "user_name" in a single deploy | Consumers are already parsing the old field name. Renaming it breaks their code silently — the field just comes back undefined. |
| Apply Postel's Law: be liberal in what you accept, conservative in what you return | Reject requests that include unknown fields | If consumers send fields you don't recognize, ignore them. This lets new clients talk to old servers and vice versa without coordination. |
| Ship a changelog and use Deprecation/Sunset HTTP headers on deprecated endpoints | Remove endpoints or fields without notice | Consumers can't adapt to changes they don't know about. A changelog and headers give them lead time to update their integration. |
7. Write the OpenAPI Spec First
What
Before writing implementation code, write an OpenAPI 3.x specification that documents every endpoint, request/response schema, error case, and authentication requirement. Use this spec to generate server stubs, client SDKs, and documentation.
Why it matters
Writing the spec first forces you to think about the API from the consumer's perspective — what they'll send, what they'll receive, what can go wrong. It's much cheaper to iterate on a YAML file than to refactor implemented code and database schemas.
Common gaps
Writing the code first and generating the spec after ('documentation as an afterthought'). Writing a spec that doesn't match the implementation. Not including error responses in the spec.
| Do this | Not this | Why |
|---|---|---|
| Write the OpenAPI spec, review it with consumers, then implement to match the spec | Implement first, then reverse-engineer a spec from the code | Spec-first catches design issues before they become code. Consumer review catches usability issues before they become support tickets. |
| Include every error response in the spec with example payloads | Only document the happy path (200 responses) | Consumers spend more time handling errors than successes. Undocumented errors become surprises that break integrations in production. |
| Use the spec to generate server stubs, client SDKs, and mock servers | Treat the spec as documentation only | A spec that generates code stays in sync with the implementation. A spec that's only read by humans drifts from reality within weeks. |