REST API design best practices: URLs, status codes, errors and pagination
Mosaddik Billah 5 min read

On this page
An API is a promise to other developers: send this, get that. A well-designed one is predictable, so someone who has used two endpoints can guess how the third works. A badly designed one sends people to the documentation, or to you, for every call. These are the conventions that make a REST API predictable, with examples you can copy.
If you want to build one first, start with how to build a simple REST API with Node.js and Express. This post is about the design decisions.
URLs name things, methods say what to do#
Use nouns for URLs and let the HTTP method be the verb:
| Do this | Not this |
|---|---|
GET /orders |
GET /getOrders |
POST /orders |
POST /createOrder |
GET /orders/42 |
GET /order?id=42 |
PATCH /orders/42 |
POST /updateOrder/42 |
DELETE /orders/42 |
GET /deleteOrder/42 |
A few more rules that keep URLs consistent:
- Plural collection names:
/orders,/customers. - Lowercase with hyphens:
/order-items, not/orderItems. - Nest only for real ownership, and only one level:
/orders/42/itemsis clear;/customers/7/orders/42/items/3is hard to use. Once you have an item’s ID, give it its own URL. - Actions that aren’t create, read, update or delete can be a sub-resource:
POST /orders/42/cancel. Better that than bendingPATCHinto something it isn’t.
Use the methods for what they mean#
| Method | Does | Safe to repeat? |
|---|---|---|
GET |
Read. Never changes anything. | Yes |
POST |
Create, or trigger an action | No |
PUT |
Replace the whole resource | Yes |
PATCH |
Change some fields | Not guaranteed |
DELETE |
Remove | Yes |
“Safe to repeat” (idempotent) matters because networks fail and clients retry. Sending the same PUT twice leaves the same result; sending the same POST twice might create two orders. For a POST that must not be doubled, such as a payment, accept an idempotency key: see idempotency explained.
And never let GET change data. Browsers, crawlers and link previews all make GET requests on their own.
Return the right status code#
Clients decide what to do from the status code before they read the body. The ones you’ll use most:
| Code | Means | Use it when |
|---|---|---|
200 OK |
Worked, here’s the result | Successful GET, PATCH, PUT |
201 Created |
Something new was made | Successful POST; add a Location header with its URL |
204 No Content |
Worked, nothing to send back | Successful DELETE |
400 Bad Request |
The request is malformed or invalid | Missing fields, wrong types |
401 Unauthorized |
Who are you? | No login or API key, or it’s invalid |
403 Forbidden |
I know who you are, and no | Logged in, but not allowed |
404 Not Found |
That doesn’t exist | Unknown ID or URL |
409 Conflict |
Clashes with the current state | Email already taken, order already shipped |
422 Unprocessable Content |
Valid format, but fails a rule | Some APIs use this for validation errors instead of 400 |
429 Too Many Requests |
Slow down | Rate limit hit; add Retry-After |
500 Internal Server Error |
Our fault | Unexpected errors |
The one to avoid above all: 200 OK with {"error": "..."} in the body. Clients, monitoring and the fetch error handling most people write all treat 200 as success.
Make errors useful#
Use one error shape across the whole API, with a message a human can act on and a code a program can check:
{
"error": {
"code": "validation_failed",
"message": "Some fields are invalid.",
"fields": {
"email": "Enter a valid email address.",
"quantity": "Must be at least 1."
}
}
}There’s also a standard for this, Problem Details (RFC 9457), with fields like type, title, status and detail. Following it is a good choice for a public API; what matters most is picking one shape and sticking to it.
Never send stack traces or database errors to the client. Log them on the server, and send a plain message with an ID that matches the log entry.
Paginate every list#
Every endpoint that returns a list will one day return too much. Paginate from the start:
GET /orders?limit=20&cursor=eyJpZCI6MTIwfQ{
"data": [ ...20 orders... ],
"next_cursor": "eyJpZCI6MTAwfQ"
}There are two common styles:
- Offset:
?page=3&limit=20. Easy, and lets people jump to page 7. But it gets slow on large tables, and if new rows arrive while someone pages through, they see duplicates or miss rows. - Cursor:
?cursor=.... The cursor encodes where the last page ended, for example the last ID. Fast at any depth and stable while data changes, but you can only go forward and back.
Use cursors for feeds and anything large or fast-moving; offsets are fine for small admin lists. Either way, set a default limit and a maximum.
Filtering, sorting and fields#
Keep these in the query string and consistent across endpoints:
GET /orders?status=paid&created_after=2026-10-01&sort=-created_atA leading - for descending order is a common convention. Only allow sorting and filtering on indexed columns, or one innocent-looking request can scan a whole table.
Version from day one#
You will need to change the API in a way that breaks old clients. Plan for it:
- Put the version in the URL (
/v1/orders) or a header. The URL is simpler to see and test. - Add, don’t change. New optional fields and new endpoints don’t break anyone. Renaming or removing a field does.
- When you must break something, run
/v1and/v2side by side and give clients a date to move.
The small things that add up#
- JSON everywhere, with consistent naming: pick
snake_caseorcamelCaseand use it in every endpoint. - Dates as ISO 8601 in UTC:
"2026-10-29T11:20:00Z". - IDs as strings in JSON if they might ever exceed JavaScript’s safe integer range.
- HTTPS only, and authentication on everything that isn’t public. See authentication explained.
- Documentation with real examples, ideally an OpenAPI file so clients can generate code and try requests.
If you need an API designed or built so your Wix site, app or partners can work with your data, that’s my custom API integration work.
Comments