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.
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 commentIdiomatic 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/ordersfor orders that belong to user 42. Two levels deep is plenty. - Query strings for filtering, sorting, paging:
/articles?status=draft&sort=-createdAt&limit=20.
/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
GET /articles?limit=20&offset=0
GET /articles?limit=20&offset=20
GET /articles?limit=20&offset=40Simple. 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
GET /articles?limit=20
# response includes the cursor for the next page:
{
"data": [...],
"next_cursor": "eyJpZCI6IjEyMyJ9"
}
GET /articles?limit=20&cursor=eyJpZCI6IjEyMyJ9The 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.
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.
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.
{
"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 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"}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"
}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."
}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
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.