webdev.complete
πŸ¦’ The API Zoo
🌐The Web Beneath
Lesson 62 of 117
25 min

REST Done Right

Resources, verbs, status codes, pagination, OpenAPI.

REST is a style, not a standard. It says: model your system as nouns (resources), use HTTP verbs as actions, return status codes that match the result, and let URLs be meaningful. Done well, REST APIs are easy to learn from a list of endpoints. Done badly, they look like RPC wearing a clown costume.

Resources, verbs, and URLs

Every thing in your API is a resource: users, articles, comments, orders. URLs identify them. Methods act on them. The combination of method + URL is the whole API surface.

bash
GET    /articles            # list articles
POST   /articles            # create one
GET    /articles/42         # get article 42
PUT    /articles/42         # replace article 42
PATCH  /articles/42         # update some fields of article 42
DELETE /articles/42         # remove it

GET    /articles/42/comments      # list comments on article 42
POST   /articles/42/comments      # add a comment
GET    /articles/42/comments/9    # one specific comment

Idiomatic URL shape

  • Plural nouns for collections: /articles, not /article.
  • Lowercase, with hyphens if you need word breaks: /blog-posts, not /BlogPosts.
  • No verbs in the URL. /users/42, not/getUser?id=42. The verb is the HTTP method.
  • Hierarchy for ownership: /users/42/orders for orders that belong to user 42. Two levels deep is plenty.
  • Query strings for filtering, sorting, paging: /articles?status=draft&sort=-createdAt&limit=20.
The actions-as-routes anti-pattern
URLs like /users/42/activate, /articles/9/publishcreep in when a domain operation doesn't map cleanly to CRUD. You have two choices: model the operation as state change with PATCH /articles/9 { "status": "published" }, or accept that this is RPC and document it. Either is fine. The middle ground - fake REST with verbs in URLs - is the worst of both worlds.

Pagination: cursor vs offset

Two ways to page through a long list.

Offset pagination

bash
GET /articles?limit=20&offset=0
GET /articles?limit=20&offset=20
GET /articles?limit=20&offset=40

Simple. The user can jump to page 17 directly. But for big datasets, OFFSET 100000is slow (the database scans the rows it's skipping). And if rows are inserted while you page, you can see duplicates or misses.

Cursor pagination

bash
GET /articles?limit=20
# response includes the cursor for the next page:
{
  "data": [...],
  "next_cursor": "eyJpZCI6IjEyMyJ9"
}

GET /articles?limit=20&cursor=eyJpZCI6IjEyMyJ9

The cursor is an opaque token (often a base64-encoded id and timestamp) that says "start after this point." The query becomes WHERE id > cursor LIMIT 20, which uses the index and stays fast. Trade-off: no random jump to page 17.

Use cursor pagination for big or growing collections (timelines, activity logs). Use offset for small, static lists where users appreciate "page 17".

Versioning

APIs evolve. Once external clients depend on your shape, breaking changes need a versioning story. Three common approaches:

  • URL path: /v1/articles, /v2/articles. Loud, easy to reason about, easy to route. The most common choice.
  • Header: Accept: application/vnd.example.v2+json. Clean URLs but harder to debug.
  • Date-based: Stripe uses Stripe-Version: 2024-04-10. Lets you pin a snapshot rather than a major number.

Whichever you pick, version once you have outside consumers. Internal APIs don't need versions. Just deploy together.

OpenAPI: docs that are also a contract

OpenAPI (formerly Swagger) is a YAML/JSON schema for describing REST APIs. From one document you get: human-readable docs (Swagger UI, Redoc), generated client SDKs in any language, and contract tests that compare the spec to real responses.

openapi.yaml - excerpt
paths:
  /articles/{id}:
    get:
      summary: Get one article
      parameters:
        - in: path
          name: id
          schema: { type: integer }
          required: true
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Article"
        "404":
          description: Not found
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

Tools like orval, openapi-typescript, and openapi-generator turn that spec into TypeScript types, fetch wrappers, or whole client libraries. Write the spec, get the SDK free.

Errors: RFC 7807 problem details

Every API needs an error shape. Pick one and stick to it. RFC 7807defines a standard JSON shape so clients don't have to learn 200 different error formats.

response with Content-Type: application/problem+json
{
  "type": "https://api.example.com/problems/validation",
  "title": "Validation failed",
  "status": 422,
  "detail": "The email field is not a valid email address.",
  "instance": "/articles/42",
  "errors": [
    { "field": "email", "message": "must be a valid email" }
  ]
}

The mandatory fields are:

  • type - a URI identifying the problem type.
  • title - short human-readable summary.
  • status - same status code as the HTTP response.
  • detail - longer explanation specific to this occurrence.
  • instance - a URI for this specific occurrence (often the request path).

You can add your own fields (like errorsabove). Clients ignore what they don't understand. Servers stay consistent.

A complete CRUD endpoint, end to end

POST /articles
POST /articles HTTP/1.1
Content-Type: application/json
Authorization: Bearer ...

{"title":"Hello","body":"world"}

HTTP/1.1 201 Created
Content-Type: application/json
Location: /articles/42

{"id":42,"title":"Hello","body":"world","createdAt":"2026-05-24T10:00:00Z"}
GET /articles?limit=2
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "v3"
Cache-Control: public, max-age=60

{
  "data": [
    {"id":42,"title":"Hello"},
    {"id":41,"title":"Yesterday"}
  ],
  "next_cursor": "eyJpZCI6NDF9"
}
DELETE /articles/42 by a non-owner
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json

{
  "type": "https://api.example.com/problems/forbidden",
  "title": "Forbidden",
  "status": 403,
  "detail": "You do not own this article."
}
Production niceties
Add a request-idresponse header. Include it in errors and logs. When a customer reports a problem, "please send me the request-id" replaces an hour of detective work.

Quiz

Quiz1 / 4

Which is the most REST-idiomatic URL?

Recap

  • REST: resources are nouns, methods are verbs, URLs identify things, status codes describe outcomes.
  • Plural nouns, lowercase, hierarchy for ownership. No verbs in URLs.
  • Offset pagination for small lists. Cursor pagination for big or changing ones.
  • Version once you have external consumers. Path, header, or date all work.
  • OpenAPI for the spec. RFC 7807 for errors. Request IDs for everything.