REST Resource Design That Ages Well
The admin panel had a delete button. It was an <a href="/admin/posts/912/delete">, styled red, and it had worked fine for three years.
Then the support team got a browser extension. A page-speed thing that quietly prefetched every link on a page so clicks would feel instant.
By lunch, 1,400 posts were gone.
Nobody clicked anything. The extension did exactly what it advertised: it fired a GET at every href on the page, including the red ones, each one carrying the support agent’s session cookie. The server saw an ordinary authenticated GET, ran the delete, and returned a cheerful 302. Fourteen hundred times.
The part worth sitting with: the extension was not buggy. It was allowed to assume a GET changes nothing, because that assumption is written into HTTP itself. The spec calls out this exact mistake by name, warning about resources like page?do=delete, and it puts the blame squarely on the resource owner rather than on the crawler.
The admin panel broke the contract first. The extension just found out.
That is what this whole topic is actually about. Not pretty URLs. Not purity. The fact that an enormous amount of software you did not write already has firm opinions about what your verbs mean, and you can either cooperate with it or spend your career fighting it.
Most “REST” APIs are RPC with slashes
Open a random /api and you’ll find something like this:
POST /api/getUserById
POST /api/createUser
POST /api/updateUserEmail
POST /api/deleteUser
That is not REST. That’s remote procedure call with a URL router bolted on: a function name in the path, arguments in the body, everything shipped over POST because POST is the verb that never asks questions.
Before the pedantry starts, let me be clear: this works. Large, profitable, well-run APIs look exactly like this. If your API has one consumer, your own frontend, and every call goes through fetch with an auth header, RPC-with-slashes will not be the thing that kills you.
But you are paying for it, and it’s worth reading the bill:
- Caching is off the table. Everything is a POST, so no browser, CDN or reverse proxy will ever serve a repeat from cache. You’ll rebuild caching by hand later, badly.
- Nothing can retry safely. A proxy has no idea whether
POST /api/updateUserEmailis replayable. So it won’t replay it, and neither will any well-behaved client. Every timeout becomes a human decision. - The vocabulary is yours alone.
getUserById,fetchUser,userGetandreadUserare four names for the same idea, and your API will accumulate all four. New endpoints get whatever verb the author was thinking about that Tuesday. - Nothing is addressable. You cannot paste a thing into Slack, bookmark it, or hand it to a support script, because things don’t have URLs. Only procedures do.
There’s a well-known ladder for this, usually drawn as four rungs: one URL for everything, then many URLs, then proper verbs on those URLs, then hypermedia links. Almost every API you have ever used lives on rung two or three and stops there. That’s not a failure. Rung three is where the practical value is, and this article is about getting there deliberately instead of by accident.
Resources are nouns; the verb is already in the request
The whole move is one sentence: name the things, not the operations, because HTTP already shipped you the operations.
/api/getUserById puts a verb (get), a noun (User), and a lookup strategy (ById) into a path segment. Every one of those is redundant. GET /users/42 says all three, and it says them in the part of the request that proxies, caches and clients already read.
So the design job stops being “what functions do I expose?” and becomes “what nouns does my system have, and what does each of the five verbs mean when applied to one?”
Two URL shapes carry almost everything:
- A collection.
/articlesis the set of all articles. - An item in it.
/articles/a1b2is one specific article.
Cross those two shapes with the standard methods and you get a grid that a client developer can guess without reading your docs:
Notice how many cells are 405 Method Not Allowed. That’s a feature. A 405 is you saying “that verb is real, this resource just doesn’t do it”, which is far more useful than a 404 that leaves the client wondering if they typo’d the URL. (Status codes get their own treatment in Status Codes and an Error Contract Clients Can Trust.)
Those colours in the figure are the thing most people get wrong, so let’s fix that before going further.
Safe and idempotent are not the same word
These two words get used interchangeably in code review, and they mean genuinely different things. Both definitions come straight from the HTTP semantics spec, and both are shorter than you’d expect.
Safe means the client did not ask for a state change. The spec’s phrasing is that the method’s defined semantics are “essentially read-only”. Four methods are defined as safe: GET, HEAD, OPTIONS and TRACE.
Idempotent means N identical requests land the same as one. The spec’s phrasing: “the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request.” Idempotent methods are PUT, DELETE, and every safe method.
Read that second list again, because it contains the punchline: safe implies idempotent. Doing nothing twice is the same as doing nothing once. So these aren’t two overlapping circles, they’re nested boxes.
Three consequences fall straight out of those definitions, and they’re the reason any of this matters.
Idempotent is about intended effect, not about your implementation
You do not “break” idempotency by writing a row to an audit log on every DELETE. The spec is explicit: a server is free to log each request separately, keep a revision history, or have other non-idempotent side effects, and the method stays idempotent. The promise is about what the client asked for, not about what your process happens to do.
Same on the safe side. Appending to an access log on every GET is safe. Charging an advertiser when someone GETs an ad is safe. The client didn’t request that behaviour and can’t be held responsible for it.
Idempotent does not mean “identical response”
This trips people constantly. DELETE /articles/a1b2 twice gives you a 204 and then a 404. Different responses, so people conclude DELETE isn’t idempotent.
It is. The effect on the server is identical: the article is gone either way. The spec even says so directly, noting that a client can retry knowing “repeating the request will have the same intended effect, even if the original request succeeded, though the response might differ.”
Somebody else is already making retry decisions using these words
This is the practical bit. The spec tells intermediaries: a proxy MUST NOT automatically retry non-idempotent requests, and a client SHOULD NOT retry a non-idempotent method unless it knows better. It also says a client SHOULD NOT automatically retry a failed automatic retry.
So your method choice is a signal to infrastructure you’ll never configure. Label an operation PUT and a load balancer may replay it after a connection drop. Label it POST and it won’t.
Which leaves the obvious hole: creating things is genuinely not idempotent, and creating things is the operation you least want to happen twice. Two POST /charges calls are two charges. HTTP has no answer for that on its own, which is exactly why Idempotency Keys for Money-Safe Endpoints exists as its own topic. The short version: the client generates a key, you store key to response, and a repeat returns the stored response instead of charging again.
Mapping the verbs without lying
The grid earlier gave you the shape. Here are the details that bite.
GET and HEAD
GET fetches. HEAD fetches the headers only, which is how a client checks size or freshness without pulling the body.
The one rule that matters is the one from the opening story: a GET must never be the way an action happens. Not through a query param, not through a hidden flag, not “just for the admin panel”. The spec’s own example is a resource like page?do=delete, and it says that if the purpose is an unsafe action, the owner MUST disallow it via a safe method. Break that and you’re not fighting a bug, you’re fighting every automated GET on the internet.
If you want a body on a read, see the QUERY section at the end. Do not put a body on a GET. It has no defined semantics, and intermediaries are entitled to drop it.
POST is the “anything” verb
The definition is deliberately wide: POST asks the target resource to process the enclosed representation “according to the resource’s own specific semantics”. That’s it. POST means whatever your resource says it means.
That vagueness is why RPC-over-POST works at all, and why POST is the right home for anything the other four verbs can’t express honestly.
When a POST creates something, say so properly:
POST /articles HTTP/1.1
Content-Type: application/json
{"title": "Ship it on Friday", "body": "..."}
HTTP/1.1 201 Created
Location: /articles/a1b2
Content-Type: application/json
{"id": "a1b2", "title": "Ship it on Friday", "body": "..."}
The 201 plus the Location header is not decoration. It’s how the client learns the URL of the thing it just made, without you inventing a convention. The spec says an origin server SHOULD do exactly this.
PUT replaces. PATCH edits. That difference eats data.
PUT requests that the state of the target resource “be created or replaced with the state defined by the representation enclosed in the request”. Replaced. The body is not a patch, it is the new resource, whole.
So this is a bug:
// the client wants to change one field
await fetch('/users/42', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: '[email protected]' }), // ← the whole user now
});
If your handler is a naive UPDATE users SET ... WHERE id = $1 built from the body keys, you got lucky and nothing broke. If it honestly implements PUT, the user’s name, plan and avatar are gone, because the client just said “the user is now exactly this.”
A good PUT handler validates that the body is a complete, legal representation and rejects it with a 400 if it isn’t. That turns a silent data loss into a loud error, which is the trade you always want. (See Validating at the Boundary for how to do that once, at the edge.)
PATCH is the partial one, and it comes with a wrinkle people skip: PATCH does not define what the body means. The PATCH spec is blunt that PATCH “is neither safe nor idempotent”, and the body is a patch document whose format you must declare. Two standard ones exist:
PATCH /users/42 HTTP/1.1
Content-Type: application/merge-patch+json
{"email": "[email protected]", "avatarUrl": null}
That’s JSON Merge Patch. Present keys are set, and null means delete this field. Which gives you the famous gotcha: with merge patch you cannot set a field to null, because null is the delete instruction. If your domain has nullable fields that clients need to explicitly null out, merge patch quietly cannot express it.
PATCH /users/42 HTTP/1.1
Content-Type: application/json-patch+json
[
{ "op": "replace", "path": "/email", "value": "[email protected]" },
{ "op": "remove", "path": "/avatarUrl" }
]
That’s JSON Patch: an ordered list of operations. More expressive, more precise, more annoying to write, and it can express null. It also supports test ops, which lets a client say “only apply this if the current value is what I think it is”.
Most APIs do neither and just accept Content-Type: application/json with a bag of fields to merge. That’s fine, honestly, as long as you document it, because a client cannot guess whether your null means “delete” or “set to null”. Undocumented PATCH semantics are how you end up with a support ticket titled “your API deleted my avatar”.
DELETE is more subtle than it looks
The spec’s framing is worth internalising: DELETE “requests that the origin server remove the association between the target resource and its current functionality”, and it explicitly compares this to rm in UNIX. It’s about the URI mapping, not about bytes on a disk. It says the underlying representations “might or might not be destroyed” and the storage “might or might not be reclaimed”.
Which is a spec-blessed way of saying: soft deletes are perfectly RESTful. DELETE /articles/a1b2 that sets deleted_at = now() and makes the URL start returning 404 has honoured the method completely. You do not need to burn the row to use DELETE.
URL shapes that age well
Once you’re naming nouns, a handful of small decisions decide whether the API still reads well in three years.
Plural collections. /articles, not /article. The reason isn’t grammar, it’s that /articles and /articles/a1b2 read as “the set” and “one from the set”, which is exactly the relationship. Singular collides awkwardly the moment you nest. Whatever you choose, choose once. An API with /users and /invoice in it tells every reader that nobody was in charge.
Kebab-case, lowercase, no trailing slash, no extensions. /purchase-orders, not /purchaseOrders or /purchase_orders or /purchaseOrders.json. Paths are case-sensitive, and camelCase in a URL means someone will type it wrong at 2am. Content type belongs in the Accept header, not in a file extension.
Don’t ship sequential integer IDs. /orders/1041 looks harmless and leaks two things. First, enumeration: anyone can walk 1039, 1040, 1042 and find out what exists (yes, authorization should stop them reading it, but you’ve handed them a map and a rate at which to try). Second, business intelligence: order 1041 today and 1102 next week tells a competitor you did 61 orders this week. Use a random opaque id. Prefixed ULIDs (ord_01J8Z...) are pleasant because they sort by creation time, are unguessable, and tell a support engineer what kind of thing they’re looking at when it turns up in a log.
Don’t leak your schema. This is the one that quietly destroys APIs. Your URL is a public contract that mobile clients will hold you to for years. Your table layout is an implementation detail you want to change on a Tuesday afternoon.
GET /user_accounts/1041/user_account_settings_rows ← your tables, exposed
GET /users/usr_8kq2/settings ← your domain
The day you split user_accounts into two tables, or move settings into a document store, the first URL is either a lie or a breaking change. The second one doesn’t care. There is no rule that your resources must map one-to-one onto tables, and the good APIs generally don’t. (Once a URL is public, changing it is a versioning problem, which is a much more expensive problem than picking a better name now.)
Nesting: fine once, a smell three times
Nesting starts out feeling like organisation:
GET /users/1/posts/2/comments/3
Look at it as an engineer instead of a librarian, and it’s mostly a pile of questions you now have to answer:
The deep URL is not more correct, it’s more work. Every segment is a claim you must verify on every request or silently ignore, and “silently ignore” is how you get a URL where /users/999/posts/2/comments/3 cheerfully returns comment 3.
The rule I’d actually defend:
Nest one level to express containment. Stop there. Once a thing has its own identity, give it its own top-level URL.
So GET /posts/p2/comments is good: it reads as “the comments belonging to this post”, it’s the natural way to list them, and there’s exactly one relationship to check. But the individual comment gets /comments/c3, because a comment has an id and can be fetched, edited and deleted without anyone caring who its parent is.
Having two URLs for the same collection is fine and normal:
GET /posts/p2/comments # the natural listing
GET /comments?post=p2 # the same set, from the other direction
GET /comments?author=usr_8kq2&since=2026-07-01
That third line is the payoff. Once relationships are query params, they compose. With nesting they don’t: there is no path that means “comments by this author across all posts” unless you invent another hierarchy. Filters also map onto database indexes in an obvious way, and there’s a whole article on getting the shape right in Pagination, Filtering and Sorting.
The actions that aren’t CRUD
Here’s where every REST article goes vague and every real API has a decision to make. Refund an order. Publish a draft. Cancel a subscription. Resend an invite. Retry a failed job.
None of those are “create a noun” or “update a field”. So what do you do?
Option 2 deserves the harsh label. PATCH /orders/o_88 with {"status": "refunded"} looks like the RESTful choice, which is why it’s so popular and so bad. It pretends a business process is a field assignment. Money moves. An email sends. A ledger entry appears. None of that is “the status field changed”, and dressing it up as a field edit invites the client to believe it can set status to anything it likes, at which point you’re writing a state-machine validator inside a PATCH handler and wondering how you got here.
The useful reframe is this: most non-CRUD actions are secretly a noun you haven’t named yet.
- “Refund the order” → a
Refundhappened.POST /orders/o_88/refunds→201,Location: /refunds/rf_19. - “Cancel the subscription” → a
Cancellationhappened, with a reason and an effective date.POST /subscriptions/s_4/cancellation. - “Publish the draft” → a
Publicationhappened, at a time, by someone.
Once you name it, you get things you didn’t ask for and will absolutely want later: the operation has a URL, so support can look it up. It’s listable, so GET /orders/o_88/refunds answers “was this refunded, and how many times?” without a database console. It’s auditable. And because creation is a POST, it slots straight into the idempotency key pattern.
And when it really is just a button? Then this is fine:
POST /orders/o_88/refund
POST /jobs/j_12/retry
POST /invites/i_9/resend
A verb on a sub-path, always POST, never GET. It’s RPC, and it’s honest RPC: nobody reading it thinks it’s a noun. That’s strictly better than contorting a real action into a fake PATCH. The bar to clear is intent, not vocabulary.
Try the whole thing yourself. Each of these is a requirement someone will hand you in a real ticket:
HATEOAS, briefly and honestly
The fourth rung of that ladder is hypermedia: responses carry links telling the client what it can do next, so the client never hard-codes a URL and you can move things around freely.
{
"id": "ord_88",
"status": "paid",
"_links": {
"self": { "href": "/orders/ord_88" },
"refund": { "href": "/orders/ord_88/refunds", "method": "POST" },
"invoice":{ "href": "/invoices/inv_31" }
}
}
The theory is genuinely elegant. The client asks “can I refund this?” by checking whether the refund link is present, rather than reimplementing your business rules in TypeScript. Your permission logic lives in one place. You can relocate URLs without a client release.
The practice, as of 2026: almost nobody does this, and you probably shouldn’t either. Clients hard-code the URLs anyway, because a developer with a deadline reads one example response, copies the path, and ships. The extra payload is real. And the discoverability problem that hypermedia solves at runtime got solved statically instead, by OpenAPI schemas and generated typed clients, which give you autocomplete in your editor rather than link-following at runtime. That fight is over and codegen won.
There is one exception you should absolutely take, and you already use it: cursors and next links in paginated responses.
{
"items": [ "..." ],
"next": "/comments?post=p2&after=c_01J8Z6"
}
That’s hypermedia, and it works for exactly the reason the theory predicts. The client genuinely cannot construct that URL itself, because the cursor encodes server-side state. So the server hands it over. Every good paginated API does this, and nobody calls it HATEOAS.
Take that lesson and skip the rest: ship a link when the client couldn’t have built the URL itself. Otherwise don’t bother.
QUERY: the first new verb in sixteen years
One genuinely new thing, and it’s directly relevant to resource design, so it’s worth knowing about even though you can’t lean on it yet.
The problem it solves is old. You have a real search: forty filter fields, a nested boolean expression, a list of 300 ids. You’d like it to be a GET, because a search is a read, and reads should be cacheable and retryable. But it doesn’t fit in a URL. The HTTP spec only recommends that senders and recipients support URIs of at least 8000 octets, and you don’t control every proxy in the path. URLs also land in access logs and bookmarks, which is a bad home for a customer’s filter criteria.
So everyone does this:
POST /search
Content-Type: application/json
{"filters": { "..." }, "sort": "-published"}
And in doing so throws away everything. That POST is not safe, so a crawler won’t touch it and a cache will ignore it. It’s not idempotent, so no proxy will retry it. You’ve labelled a read as a potentially destructive write, because POST was the only verb with a body.
RFC 10008 fixed this in June 2026. QUERY is safe and idempotent like GET, carries a request body like POST, and is explicitly cacheable, with the requirement that the cache key must incorporate the request content. A resource advertises support with an Accept-Query response header listing the query formats it takes.
QUERY /articles HTTP/1.1
Host: example.org
Content-Type: application/json
{"filters": {"tag": "http", "published_after": "2026-01-01"}, "sort": "-published"}
Consistency beats cleverness
The last thing, and the one that actually decides whether an API is pleasant.
Every inconsistency in your API is a lookup. If /users paginates with ?page= and /orders paginates with ?offset=, nobody can guess anything, and every single endpoint has to be read before it can be called. Ten endpoints that are all slightly wrong in the same way are easier to work with than ten endpoints where three are perfect and seven are individually special.
So make these decisions once, write them in a document your team will actually open, and then stop having the argument:
- Plural or singular collections. (Plural.)
- Casing in paths and in JSON fields. Pick one for each and never mix.
- ID format. Opaque, everywhere, no exceptions.
- Timestamps: which format, which timezone. (RFC 3339 UTC, with a
Z. Not epoch millis, not local time.) - What the second DELETE returns.
- Whether
nullin a PATCH body deletes a field. - One error shape, for every endpoint, forever.
- The pagination contract.
The single best thing you can do for an API’s lifespan is make it boring and predictable. A clever endpoint is a thing someone has to learn. A boring one is a thing they can guess.
And when the guess is right the first time, they’ll stop reading your docs and just build. That’s the whole goal.
Summary
- Most “REST” APIs are RPC with slashes, and that’s often fine. Just know what it costs you: no caching, no safe retries, no addressable things, and a vocabulary only your team knows.
- Resources are nouns; HTTP already shipped the verbs. Design the collection (
/articles) and the item (/articles/a1b2), then define what each method means on each. Return405for the cells that don’t apply. - Safe and idempotent are different, and safe is a subset of idempotent. Safe: GET, HEAD, OPTIONS, TRACE. Idempotent: those plus PUT and DELETE. Neither: POST and PATCH.
- Idempotent is about the intended effect, not the response. A second DELETE returning
404is still idempotent. Logging every request doesn’t break it either. - Never let a GET perform an action. Prefetchers, crawlers and caches are entitled to fire it, and they will.
- PUT replaces, PATCH edits. A PUT with a partial body means “the resource is now only this”. Reject incomplete PUT bodies with a
400, and document whatnullmeans in your PATCH. - Nest one level for containment, then stop.
/users/1/posts/2/comments/3is a pile of relationships you must verify;/comments/c3plus?post=p2is one id and one indexable filter that composes. - Non-CRUD actions are usually an unnamed noun. If someone will need to look up the occurrence later, make it a resource (
POST /orders/o_88/refunds→201+Location). If it’s just a button,POST /orders/o_88/refundis honest RPC and fine. Faking it as a status PATCH is the only wrong answer. - HATEOAS lost to OpenAPI and codegen. Ship links only where the client couldn’t build the URL itself, which in practice means pagination cursors.
- QUERY (RFC 10008, June 2026) is a safe, idempotent, cacheable method with a body. Node parses it; framework, browser and CDN support is thin. Keep
POST /searchfor now and watch this one. - Consistency beats cleverness. Decide the boring things once, write them down, and let people guess correctly.