REST API Design: The Complete Guide to Building APIs That Last

Designing a REST API is easy. Designing one you won’t quietly hate a year from now is the hard part.
The moment another developer writes code against your endpoints, you’ve made them a promise. This URL works. This field is called price. This call returns a 201. Change your mind later and you’re not refactoring your own code anymore. You’re breaking theirs, in production, probably on a Friday.
That’s why API design has so many “rules.” They aren’t bureaucracy. They’re the accumulated scar tissue of people who painted themselves into corners that were expensive to climb out of. The good part is that most of those decisions have a clear right answer, and I’ve collected them here in one place. Read it top to bottom, or jump to the section you need and come back later.
The whole goal is an API that’s predictable. A developer who has never seen your docs should be able to guess how it works and be right most of the time.
What REST Actually Asks of You

REST is a style, not a library you install. A few of its constraints matter every single day:
The client and the server stay independent. Your front end doesn’t care how the database is shaped, and the server doesn’t care whether the caller is a browser, a mobile app, or a cron job.
Every request stands on its own. The server keeps no memory of you between calls, so each request carries whatever it needs to be understood. This sounds like a limitation until you try to run ten copies of your service behind a load balancer, at which point it’s the only thing that saves you.
Responses say whether they can be cached, and resources live behind a small, standard set of HTTP methods. That last constraint is the one people break constantly, and most of this guide is really about respecting it.
You don’t need the textbook definition. You need to apply these ideas, and it starts with naming.
Resource Naming and URL Design

A resource is a thing your API exposes: a user, an order, a product. URLs name those things. So build them out of nouns, and let the HTTP method supply the verb.
Nouns, not verbs
Put the action in the path and you end up duplicating the verb that HTTP already gives you, then drowning in hundreds of one-off endpoints.
❌ GET /getAllProducts
❌ POST /createProduct
❌ POST /products/5112/delete
✅ GET /products
✅ POST /products
✅ DELETE /products/5112
Pluralize your collections
Pick plural and stick with it. A plural name reads correctly whether you’re grabbing the whole list or one item out of it:
GET /users # everyone
GET /users/42 # one person
POST /users # add someone
❌ Don’t Do That :
Name the list /users but a single record /user/42 and you’ve just forced every client to remember which endpoints are special.
Nest relationships, but not very deep
Show ownership by nesting. A customer’s orders sit under that customer:
GET /customers/42/orders # customer's orders
GET /customers/42/orders/18 # one of them
GET /customers/42/orders/18/items/3 # one of them
Two or three levels are plenty. The moment you find yourself writing urls like this, stop immediately:
- ❌
/customers/42/orders/18/items/3/supplier/9or - ❌
/companies/12/warehouses/4/aisles/2/shelves/45/products/908URLs like that are miserable to build and break the moment anything moves. Promote the thing to a top-level resource and filter instead.
Keep paths boring
Lowercase everything, and use hyphens for multi-word names: /shipping-addresses, never /shippingAddresses or /Shipping_Addresses. Skip trailing slashes. Skip file extensions like .json (the content type lives in a header, not the URL). And settle on one casing style for your JSON fields, snake_case or camelCase, then never mix them.
I know “be consistent” sounds like filler advice. It isn’t. Consistency is the feature that lets a developer guess the next endpoint correctly instead of opening your docs for every call.
HTTP Methods: Let the Verb Carry the Meaning

The method says what you’re doing. Get this right and your URLs stay stable while the behavior stays obvious.
| Method | What it’s for | Safe? | Idempotent? |
|---|---|---|---|
GET | Read something | Yes | Yes |
POST | Create something, or kick off an action you can’t safely repeat | No | No |
PUT | Replace a resource completely | No | Yes |
PATCH | Change part of a resource | No | Maybe |
DELETE | Remove a resource | No | Yes |
QUERY | Run a complex read, with the query in the body, read more about it in this section QUERY | Yes | Yes |
“Safe” means the call doesn’t change anything on the server. “Idempotent” means calling it ten times leaves things exactly as calling it once would.
Two things fall out of this that you can’t fudge. First, a GET must never change state, ever. A “GET that deletes” looks harmless until a browser prefetcher or a crawler wipes half your database for you. Second, PUT and PATCH are not interchangeable: send the whole object to PUT, send only the changed fields to PATCH.
You need to implement “Idempotency” for POST too, but it doesn’t come from the HTTP spec. It’s a pattern you can add to your API so clients can safely retry a request that creates something without accidentally creating it twice. More on that later. You can read more about idempotency here
QUERY: The Read That Outgrew the URL

Sooner or later every API grows a read that doesn’t fit in a URL. A search screen with twenty optional filters. A report builder. Anything where the query is a document, not three parameters.
Until recently you had two workarounds, and both were compromises. You could keep stuffing the query string until it hit a limit you didn’t pick — the spec only asks systems to accept around 8,000 octets, plenty enforce less, and every filter now sits in server logs, browser history, and bookmarks. Or you could tunnel the search through POST /products/search, which works but lies. POST is neither safe nor idempotent, so caches ignore the response and a client can’t retry a dropped request without wondering what it just did twice. You know it’s a read. HTTP doesn’t.
HTTP hadn’t added a general-purpose method since PATCH in 2010, and in June 2026 it finally did. RFC 10008 — years in the making, and called SEARCH in its early drafts — defines QUERY: safe and idempotent like GET, carries a body like POST.
QUERY /products HTTP/1.1
Host: api.example.com
Content-Type: application/json
Accept: application/json
{
"category": "books",
"price": { "min": 10, "max": 50 },
"in_stock": true,
"sort": ["-created_at", "name"]
}
The results come back in a normal 200, same as any read. But because the method itself now says “this is a read,” everything in the middle can finally act like it. Clients and proxies can retry a QUERY after a connection failure without the idempotency-key ceremony that POST needs. Responses are cacheable, with the request body folded into the cache key so two different queries never collide. And conditional requests work just like GET: send If-None-Match, get a cheap 304 back when nothing changed.
A few rules ride along. Content-Type on the request is mandatory; a server must reject a QUERY without one rather than sniff the body and guess. And the status codes behave the way you’d hope: 415 when you don’t support that query format, 400 when the body doesn’t match its declared type, 422 when the query parses fine but can’t actually run.
The spec also hands you two response headers worth knowing. Location can point at a URL that re-runs the same query as a plain GET, so clients can repeat it without resending the body. Content-Location can point at a stored copy of this specific result. Both optional, both great for expensive queries.
Discovery is pleasantly boring: a resource can advertise support with an Accept-Query response header listing the formats it takes (Accept-Query: application/json), you can look for QUERY in the Allow header of an OPTIONS response, or you just send one and handle the 405.
❌ Don’t Do That :
Don’t reach for GET with a request body instead. The spec assigns a GET body no meaning, and any proxy, cache, or server along the path is free to drop it. Elasticsearch gets away with this inside a cluster you control; a public API won’t.
Should you ship QUERY today? Check your stack first. The RFC is brand new, so framework, gateway, CDN, and client library support is still filling in — and browser calls pay a CORS preflight, since QUERY isn’t on the safelist. Where your tooling isn’t ready, a clearly named POST /search stays the honest fallback. And for simple filters, keep doing what the filtering section says: even the RFC notes that short queries belong in the query string. QUERY is for the ones that outgrew it.
Status Codes: Tell the Truth About What Happened

Return the code that actually describes the outcome. Learn a small set well rather than reaching for exotic ones nobody recognizes.
Success (2xx)
200 OK— the everyday success.201 Created— you made something. Add aLocationheader pointing to it.202 Accepted— you took the request but you’re processing it later.204 No Content— it worked, there’s nothing to send back (great forDELETE).
Redirects (3xx)
301 Moved Permanently— it lives at a new URL now.304 Not Modified— the caller’s cached copy is still good (this is your friend; more on it under caching).
Client Side Error / Mistakes (4xx)
400 Bad Request— the request itself is malformed.401 Unauthorized— you don’t know who they are.403 Forbidden— you know who they are, they just can’t do this.404 Not Found— no such thing.405 Method Not Allowed— right URL, wrong verb.409 Conflict— clashes with current state, like a duplicate.422 Unprocessable Entity— well-formed but it failed validation.429 Too Many Requests— they’re hitting you too hard.
Server Side Error / Mistakes (5xx)
500 Internal Server Error— something blew up on your side.503 Service Unavailable— you’re down or overloaded right now.
There’s one sin worth calling out by name: returning 200 OK with an error buried in the body. Clients trust the status line. If you lie there, every error handler downstream lies too.
Requests and Responses

Use JSON. For request bodies, for responses, for basically everything except shoving a file across the wire. And actually set the header, Content-Type: application/json. A JSON-shaped string without that header just makes clients guess and parse by hand.
After a POST or a PATCH, send the resource back. The client just changed it; don’t make them fire a second request to see what it looks like now.
Keep your response shapes predictable. For lists, wrap the data and hang the pagination info off the same object so it always lives in the same spot:
{
"data": [
{ "id": 42, "name": "Wireless Mouse", "price": 24.99 }
],
"pagination": {
"page": 1,
"per_page": 20,
"total": 137,
"next": "/products?page=2&per_page=20"
}
}
Filtering, Sorting, and Pagination

Collections grow. Plan for it from the first commit, and do it with query parameters. Never bake a limit into the path.
Filter to narrow things down:
GET /products?category=books&status=active&min_price=10
Sort with a readable convention. A leading - for descending is common and people get it instantly:
GET /products?sort=-created_at,name
Paginate anything that can return a big list. You’ve got two real options:
Offset, or page-based, pagination is the simple one. ?page=3&per_page=20. It’s perfect for small and medium datasets and for any UI that shows page numbers.
Cursor-based pagination hands back an opaque pointer to the next batch instead. ?limit=20&cursor=eyJpZCI6MTQ0fQ. It costs more to build, but it stays correct and fast even when rows are being inserted while someone pages through, which is exactly why high-traffic APIs like Stripe lean on it. If your data moves a lot or your tables are huge, this is the one.
Whichever you pick, return some metadata: a total, or next and previous links, so the client isn’t left wondering whether there’s more.
When the filters themselves outgrow the query string, that’s a job for QUERY.
Error Handling

Errors are part of your interface. Clients have to read them in code, not just with their eyes, so use one consistent shape everywhere. There’s a standard for this, RFC 9457 Problem Details, but a simple consistent envelope of your own is fine too:
{
"error": {
"code": "validation_failed",
"message": "The request contains invalid fields.",
"details": [
{ "field": "email", "issue": "must be a valid email address" },
{ "field": "age", "issue": "must be greater than 0" }
]
}
}
A good error gives the caller a stable machine code to branch on (so they check for validation_failed instead of scanning your prose), a human message for whoever’s reading the logs, and field-level detail when validation fails. What it never includes is a stack trace, a SQL fragment, or an internal file path. Those help attackers and confuse everyone else.
Versioning

Public APIs change. Version from the very first release so you can grow without breaking the people already depending on you.
The usual approaches:
URI versioning puts it right in the path, /v1/products. It’s the popular default because you can see it, route on it, and test it in a browser without thinking.
Header versioning keeps URLs clean by tucking the version into a header like Accept: application/vnd.myapi.v1+json. Tidier, but harder to eyeball and debug.
There’s also a date-based flavor that some big APIs use, where you pin a version with something like X-Api-Version: 2024-03-29. Stripe and GitHub both work this way, and it’s nice when you ship changes constantly.
Pick one and use it everywhere. The rule underneath all of them: adding an optional field is safe and needs no new version, but removing a field, renaming one, or changing its type is a breaking change and belongs in a new major version.
Security

Security isn’t a phase near the end of the project. It’s a property of every endpoint, so treat it that way.
Serve everything over HTTPS and refuse plain HTTP outright. A token sent over HTTP is a token sent to anyone listening.
For authentication, figure out who’s calling. API keys are simple and great for server-to-server traffic and identifying which app is talking to you. OAuth 2.0 and OpenID Connect are the standard when a user is granting access on their behalf. JWTs are self-contained tokens that ride along in a header and fit REST’s stateless nature nicely.
Authentication is only half of it. Authorization is the other half: knowing who someone is doesn’t mean they’re allowed to touch this record. Check permissions on every request, and default to denying.
Rate limit your endpoints so one runaway client can’t take you down. When someone trips the limit, return 429 with a Retry-After header so the well-behaved clients know to back off instead of hammering harder.
Validate everything that comes in. Assume every request is hostile until it proves otherwise. And keep secrets out of URLs entirely, because tokens in a query string end up in server logs, browser history, and whatever analytics tool is watching. Headers exist for a reason.
Performance and Caching

A few cheap wins here go a long way:
Cache-Control headers tell clients and proxies how long a response stays fresh, which spares you a flood of pointless requests for data that barely changes.
ETags pair with If-None-Match so a client can ask “has this changed?” and get back a tiny 304 Not Modified when it hasn’t. Bandwidth saved on both ends, for almost no effort.
Turn on compression (gzip or Brotli). JSON shrinks a lot and you pay basically nothing.
And let clients ask for less. Sparse fieldsets like ?fields=id,name,price and opt-in expansion like ?expand=author cut payload size and round trips, and they quietly save you from a pile of N+1 query problems.
Idempotency, So Retries Don’t Hurt
Networks drop requests halfway through, and clients retry when they do. For anything that creates a record or moves money, a double-submit is a genuine bug, not a hypothetical one.
The fix is an Idempotency-Key header. The client sends a unique key with the request; if your server sees that same key twice, it returns the original result instead of doing the work again. Stripe popularized this pattern for exactly the obvious reason: nobody wants to charge a customer twice because their phone hiccupped. Borrow it.
Documentation
An API nobody can figure out might as well be broken. Describe yours with an OpenAPI spec (you may still hear it called Swagger). One machine-readable file becomes your source of truth, and from it you can generate interactive docs, client SDKs in a dozen languages, and mock servers for testing.
Document every endpoint, its parameters, and, please, its error responses, all with real examples. The APIs that feel effortless are the ones whose docs answer your next question before you’ve finished asking it.
A Word on HATEOAS
Strict REST includes HATEOAS, where responses carry links telling the client what it can do next, so it can navigate without hardcoding URLs:
{
"id": 42,
"status": "pending",
"_links": {
"self": { "href": "/orders/42" },
"cancel": { "href": "/orders/42/cancel" },
"items": { "href": "/orders/42/items" }
}
}
In the real world most production APIs do this halfway or not at all, and they survive just fine. Treat it as a tool rather than a commandment. Add links where they genuinely help someone discover what’s possible, and don’t lose sleep over purity.
The Checklist
Pin this somewhere and glance at it before you ship:
- URLs are nouns; the method is the verb.
- Collections are plural and named consistently.
- Relationships nest no more than a two or three levels.
- Paths are lowercase and hyphenated, no trailing slash, no
.json. - One casing style for JSON fields, everywhere.
- Methods match their meaning (
GETnever writes;PUTreplaces,PATCHedits). - Honest status codes, and never a
200wrapped around an error. - JSON with the right
Content-Type. - Filtering, sorting, and pagination through query params, with metadata.
- Complex reads use QUERY where the stack supports it, never GET with a body.
- One error shape, with codes, messages, and field-level detail.
- Versioned from day one; breaking changes only in a new major version.
- HTTPS only, real auth, real authorization.
- Rate limiting with
429andRetry-After. - Caching via
Cache-Controland ETags. - Idempotency keys on anything that creates or charges.
- OpenAPI docs with examples and error cases.
FAQ
What’s the difference between PUT and PATCH?
PUT replaces the whole resource, so anything you leave out gets wiped or reset. PATCH only touches the fields you send. Reach for PUT when you’re swapping the entire thing and PATCH when you’re nudging a couple of values.
Singular or plural resource names?
Plural, consistently. /users for the list and /users/42 for one of them reads naturally both ways and saves clients from memorizing exceptions.
How should I version an API?
Version it from the first release. URI versioning (/v1/...) is the easiest to see and route, though header or date-based versioning works too. Whatever you choose, use it everywhere and only bump the version for breaking changes.
Offset or cursor pagination? Offset is simpler and totally fine for modest datasets and numbered-page UIs. Cursor pagination holds up when data is changing in real time or the tables are enormous, which is why the big APIs prefer it.
Is it still REST if I skip HATEOAS? Technically HATEOAS is part of REST, but most real APIs skip it or do it partially and still get called RESTful all day long. Add hypermedia where it actually helps; otherwise don’t sweat it.
Last Thing
None of this is clever. Good API design is mostly the discipline to be boring and predictable in all the places a developer expects you to be boring and predictable. Get the fundamentals right, version from day one, and never make someone read your source code to figure out what an endpoint does.
Do that, and your API just keeps working while everything around it churns. Which, honestly, is about the nicest thing anyone can say about one.
