mosaddik.dev
All posts

REST API design best practices: URLs, status codes, errors and pagination

Mosaddik Billah 5 min read

Software Engineering

A laptop on a wooden desk showing code and a terminal
Photo by Douglas Lopes on Unsplash
On this page
  1. URLs name things, methods say what to do
  2. Use the methods for what they mean
  3. Return the right status code
  4. Make errors useful
  5. Paginate every list
  6. Filtering, sorting and fields
  7. Version from day one
  8. The small things that add up

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/items is clear; /customers/7/orders/42/items/3 is 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 bending PATCH into 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:

JSON
{
  "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
JSON
{
  "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_at

A 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 /v1 and /v2 side by side and give clients a date to move.

The small things that add up#

  • JSON everywhere, with consistent naming: pick snake_case or camelCase and 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