
REST API guidelines
REST APIs mostly get you a long way just by leaning on HTTP: verbs for CRUD, status codes for outcomes, caching for free. The awkward parts are the things HTTP doesn't say much about — filtering, pagination, how you return totals — and every guideline solves these differently. I went through a handful of the popular ones to see what I actually want to use for my own APIs.
The ones I looked at:
- JSON:API
- JSONAPI.net
- GitHub REST API
- Google API design guidelines
- Auth0 management API
- React-Admin Simple REST data provider
Filtering and sorting
The least standardised part of any REST API. Everyone does it differently:
| Guideline | Approach | Example |
|---|---|---|
| JSON:API | filter parameter per field |
GET /articles?filter[author]=1 |
| JSONAPI.net | filter with expression language |
GET /articles?filter=equals(author,'1') |
| GitHub | Field-specific query params | GET /issues?labels=bug |
| Field names as query params | GET /books?author=Hemingway |
|
| React-Admin | JSON object in a query param | GET /posts?filter={"author":"John"} |
| Auth0 | Lucene query via q |
GET /api/v2/users?q=email:"john@example.com" |
A few observations:
- Google and GitHub are easy to write and easy to read but they don't really scale to nested filters or value comparisons.
- JSON:API and React-Admin let you filter on nested properties, but you have to pick your own convention for operators like greater-than or contains.
- JSONAPI.net's expression syntax is readable and flexible but whether you can implement it depends heavily on what your database supports.
- Auth0's Lucene queries are the most powerful and also the most coupled — you're basically exposing the query language of your search index, which tends to come back and bite you when the index changes.
Pagination
| Guideline | Approach | Example |
|---|---|---|
| JSON:API | Page-based | GET /articles?page[number]=2&page[size]=10 |
| JSONAPI.net | Page-based | GET /articles?page[number]=2&page[size]=10 |
| GitHub | Cursor-based (Link header) |
GET /issues?labels=bug |
| Cursor-based | GET /books?pagetoken=abc123 |
|
| React-Admin | Page-based | GET /posts?page=2&perPage=10 |
| Auth0 | Page-based | GET /api/v2/users?page=1&per_page=10 |
Cursor-based is nicer for large or frequently-changing data — no duplicates or skipped rows when things shift between requests. Page-based is easier for UIs that need to show "page 3 of 27". If I have to pick one I tend to go cursor-based and let the client compute display pages if it really wants them.
Response metadata
Totals, counts, next-page links — where do you put them?
| Guideline | Where | Notes |
|---|---|---|
| JSON:API | Body (meta) |
No convention for requesting it. |
| JSONAPI.net | Body | Same. |
| GitHub | Link header |
No total count. |
| Body | totalItems always included. |
|
| React-Admin | HTTP headers | Leans on standard Content-Range. |
| Auth0 | Body, opt-in | includeTotals query param toggles it. |
The Auth0 approach is interesting because counting can be expensive and making it opt-in is genuinely good for performance. The downside is the response shape changes between requests, which is annoying on the client.
Personally I like headers for this — the body stays a clean array, and totals are there when you need them without changing the shape.
The parts everyone agrees on
HTTP methods
GET— read. Safe, idempotent.POST— create, or anything that doesn't fit CRUD. Not safe, not idempotent.PUT— full replace. Not safe, idempotent.PATCH— partial update. Not safe, idempotent.DELETE— delete. Not safe, idempotent.OPTIONS/HEAD— metadata and preflight.
URI conventions
- Collection:
GET /articles - Single resource:
GET /articles/{id} - Related resource:
GET /articles/{id}/author
For actions that don't fit CRUD, make a sub-resource and POST to it:
POST /articles/{id}/publish
Resources tend to be kebab-cased (blog-posts) and field names camelCased — GitHub and Auth0 being the main exceptions that go snake_case.
Boolean field names usually carry a prefix:
is...for state —isActive,isPublishedhas...for presence —hasAccess,hasLicensecan...for capability —canEdit,canDelete
Timestamps are almost always ISO 8601 and use these three names:
createdAtupdatedAtdeletedAt(for soft deletes)
Status codes
The ones I actually reach for:
2xx — 200 OK, 201 Created, 202 Accepted, 204 No Content.
4xx — 400 Bad Request, 401 Unauthorized (not authenticated), 403 Forbidden (authenticated but not allowed), 404 Not Found, 405 Method Not Allowed, 409 Conflict, 422 Unprocessable Entity (request was valid JSON but semantically wrong).
5xx — 500 Internal Server Error, 503 Service Unavailable.
What I'd actually build on top of Elasticsearch
If the backend is Elasticsearch and the query language is already Lucene, Auth0's q parameter makes a lot of sense — you're not inventing a new filter language, you're exposing the one you already have. If the index has fields you don't want to expose, parse and rewrite the query on the way in rather than forwarding it raw.
For paging I'd support both styles: page/size for UIs, and a cursor for anything that iterates. Elasticsearch's scroll or search_after maps well to cursors; you can base64-encode the cursor so clients don't think they can construct one themselves.
Elasticsearch gives you the total count basically for free, so I wouldn't bother making it opt-in. I'd put it in a response header and keep the body a plain array — same shape every time, which is what I want when I'm writing the client.