REST API Design That Scales: Endpoints, Errors, and the Stuff That Bites You
Most API design advice sounds right and works badly. People quote Roy Fielding's thesis at you, they say "REST is a style not a standard," and you end up with a sprawling mess of inconsistent endpoints that confuses the frontend team and the mobile team and yourself six months later.
Here is the version of API design I wish someone had handed me in 2017. It is opinionated, it is practical, and it is the result of building, breaking, and rebuilding REST APIs for client work for the better part of a decade. You will not agree with all of it. That is fine. The point is to have reasons.
Start with nouns, not verbs
The first decision is whether your URLs describe resources or actions. Almost always, resources. /articles, not /getArticles. The HTTP method tells you the action: GET reads, POST creates, PATCH or PUT updates, DELETE removes.
GET /api/v1/articles
GET /api/v1/articles/{id}
POST /api/v1/articles
PATCH /api/v1/articles/{id}
DELETE /api/v1/articles/{id}
This is the entire CRUD interface. It is the same shape for users, orders, products, comments. Once your team is used to seeing this pattern, every new endpoint is obvious. You do not have to think. You just write it the same way.
The trap is when someone wants an action that does not fit the model. "Send a welcome email when a user signs up" does not belong as POST /sendWelcomeEmail. It belongs as a side effect of POST /users, or as a webhook you fire from your code, or as a separate endpoint on the user's relationship: POST /users/{id}/welcome-email. Stay with the noun-first pattern even when it is slightly awkward. The consistency pays off in six months.
Version from day one, in the URL
Put the version in the URL. Not in the header. Not in the body. In the URL. /api/v1/. Yes, this is not "pure REST." Yes, Roy Fielding did not endorse it. He also did not have to maintain a mobile app that crashes because the backend rolled out a breaking change on a Wednesday afternoon. Put the version in the URL.
When v2 ships, you keep v1 running until every client has migrated. You can run both in parallel from the same codebase with a single version-prefix switch in your router. The cost of doing this from day one is ten minutes. The cost of retrofitting it after 200 endpoints is a quarter.
Use plural nouns for collections, singular for nothing
Always plural. /articles, never /article. Why? Because you do not want a special case for the collection vs the individual. The collection is /articles, and the individual is /articles/{id}. The path is the same. The HTTP method tells you whether you are looking at one or many.
If you mix singular and plural across your API, your frontend code will have bugs. fetch('/article/' + id) works on Monday and fails on Tuesday when someone added a new endpoint with the wrong pluralization. Plural everywhere. Always.
Pagination: cursor-based beats offset-based, eventually
For your first API, offset pagination is fine. ?page=2&per_page=20. It is simple, it works, your clients will understand it. Use it until you ship.
Once your data starts to grow, switch to cursor-based pagination. The reason is consistency. If a row is inserted at page 2 while the user is paging through, offset pagination shows the same row twice or skips a row. Cursor pagination does not, because the cursor is opaque and points at a specific position, not a count from the start.
GET /api/v1/articles?cursor=eyJpZCI6MTAwfQ&limit=20
{
"data": [...],
"next_cursor": "eyJpZCI6MTIwfQ",
"has_more": true
}
The cursor is base64-encoded JSON or an opaque string. Clients treat it as a black box. Server-side, you translate it back to a database query. This pattern scales to millions of rows without weirdness.
Errors: be specific, be structured, be stable
The single biggest source of API pain is error handling. Clients have to switch on a string from your server. You will rename the string. Their app crashes. The fix is structured error responses with stable error codes.
{
"error": {
"code": "article_not_found",
"message": "No article with that ID exists.",
"details": { "id": "12345" }
}
}The code field is a stable string identifier. It never changes. The message field is human-readable. It can change. The details field is structured metadata the client can use for context. The HTTP status code is also important. Use 400 for bad input, 401 for missing auth, 403 for forbidden, 404 for not found, 409 for conflict, 422 for validation, 429 for rate limit, 500 for server error.
Once you have this pattern, your client code looks like if (err.code === 'article_not_found') showError(...). The string never changes. You can refactor the message, the details, the i18n. The code is the contract.
Authentication: bearer tokens in the Authorization header
For almost every modern API, bearer tokens in the Authorization header are the right answer. Authorization: Bearer eyJhbGciOi.... The token is opaque to the client. The server looks it up. Done.
Cookies are fine for browser-based apps talking to a same-origin backend. They are a nightmare for mobile apps, third-party clients, and microservices. Use bearer tokens unless you have a specific reason not to.
For the actual auth flow, use OAuth 2.0 if you are integrating with a third-party identity provider, or roll your own simple email/password flow with bcrypt-hashed passwords and short-lived JWTs (15 minutes) plus longer-lived refresh tokens (30 days) if you are not. Do not roll your own OAuth unless you have to. It is a year of work to do right.
Documentation: OpenAPI, generated, deployed
Write the OpenAPI spec by hand, in YAML, in your repository. Do not use a GUI tool. The spec is a contract. It belongs in code, under version control, reviewable in pull requests. Once you have it, you get client SDK generation, interactive docs, request validation, all for free.
Deploy the spec to a public URL. /api/docs. Clients will use it. Your future self, three months from now, will use it. There is no substitute.
What to skip on your first version
You do not need HATEOAS. You do not need hypermedia controls. You do not need content negotiation. You do not need GraphQL because someone said REST is legacy. You do not need gRPC because the latency is bad.
You need: a small number of well-named endpoints, versioned URLs, structured errors, bearer token auth, and an OpenAPI spec. That is enough to ship a product. Everything else is optimization for a scale you do not yet have. Build the simple version. Get feedback. Then add the complicated parts only if the data tells you they help.
That is the whole game. Nouns, versions, plural, structured errors, bearer tokens, OpenAPI. Ship.
Frequently asked questions
What is the difference between PUT and PATCH?
PUT replaces the entire resource. PATCH applies a partial update. In practice, PATCH is what you want 95% of the time, because clients usually do not want to send every field back just to change one. PUT makes sense for create-or-replace flows; PATCH for everything else.
Should I use JSON or form-encoded request bodies?
JSON, for almost everything. Form-encoded is fine for browser-submitted forms, but JSON is more flexible (nested objects, arrays, nulls), easier to parse, and the standard for programmatic API clients. Most modern frameworks handle JSON out of the box.
How do I version my API once it is in production?
You versioned it in the URL from day one, right? If not, you are going to be in pain. The escape hatch is to add an Accept header versioning layer on top of the URL, but that is messier. Do not skip this step when you are starting out.
Related articles
← More in Backend Systems