Designing a REST API that ages well
REST conventions that actually matter: resource-shaped URLs, HTTP verbs over action names, and status codes that tell the truth, with Linkstash's real routes.

"REST" gets used to mean almost anything with JSON over HTTP, and most of what people cite as a REST rule (HATEOAS, strict statelessness, no cookies ever) never shows up in a typical API and doesn't need to. What's actually worth stealing is smaller: a handful of naming and status-code conventions that make an API predictable to a developer who has never seen it before. Here's the full Linkstash route table again, because every convention below is visible in it:
| Method | Path | Auth | Success | Errors |
|---|---|---|---|---|
| POST | /auth/register | no | 201 {id, email} | 400, 409 |
| POST | /auth/login | no | 200 {token, user} | 401 |
| GET | /links | yes | 200 {data, limit, offset} | 401 |
| POST | /links | yes | 201 Link | 400, 401 |
| GET | /links/:id | yes | 200 Link | 401, 404 |
| DELETE | /links/:id | yes | 204 | 401, 404 |
Nouns in the path, verbs in the method
Notice there's no /getLinks or /deleteLink/42 anywhere in that table. The path names a resource, links, and the HTTP method says what you're doing to it. GET /links reads the collection. POST /links creates inside it. GET /links/:id reads one. DELETE /links/:id removes one. Four routes, one path shape, and the verb never has to appear in the URL because it's already sitting right there in the method.
This isn't just tidiness. It means a developer who has never seen Linkstash's docs can guess correctly that deleting a user, if that route existed, would be DELETE /users/:id, not /removeUser or /users/:id/delete. Predictability is the entire payoff, and it compounds: the tenth resource you add to a well-shaped API is as guessable as the first.
Status codes that mean something specific
201 shows up twice in that table and 200 never for the same kind of action, and that's not arbitrary. 201 Created means the request made a new thing and the response body is that thing. 200 OK means success with no such implication. Login (POST /auth/login) returns 200, not 201, because logging in doesn't create a resource, it returns a token for one that already exists.
204 No Content on delete is the other one people get wrong instinctively. Reach for 200 out of habit and you need a response body, so you either send back the deleted object (which is a little strange, the client just told you to get rid of it) or an empty {} (which is just noise). 204 says exactly what happened: success, and there is nothing to send back. Linkstash's delete route ends in res.status(204).end(), no .json() call at all, because there's genuinely nothing to serialize.
A quick status code map
200 success, here's data. 201 success, here's the new thing, with a Location-worthy identity. 204 success, no body. 400 your request is malformed. 401 we don't know who you are. 404 nothing here (or nothing here you can see). 500 we broke, not you.
404, not 403: the decision worth explaining
Look again at GET /links/:id: its only errors are 401 and 404. There's no 403 Forbidden anywhere in Linkstash's contract, and that's deliberate, not an oversight. Here's the actual handler:
// Not-yours and not-found deliberately return the same 404. See API.md.
app.get("/links/:id", requireAuth, (req, res) => {
const link = getLink(db, Number(req.params.id));
if (!link || link.userId !== req.userId) {
res.status(404).json({ error: "NotFound" });
return;
}
res.json(link);
});A link that doesn't exist and a link that exists but belongs to someone else produce the exact same response: 404. Compare that to what a 403 would communicate: "I found something at this id, and you're not allowed to see it." That's information. It tells an attacker probing sequential ids that /links/57 is a real link owned by somebody, just not them, which narrows their search. A 404 refuses to confirm or deny anything past "there is nothing here for you." From the outside, "doesn't exist" and "exists but isn't yours" are indistinguishable, and that's exactly the point: they should be.
This is a small decision with an outsized effect on how much an API leaks about data it's supposed to protect. It's worth internalizing as a default, not just a Linkstash quirk: when in doubt, prefer "not found" over "forbidden" for anything scoped to a specific owner.
Consistent error shape
One more thing this table hides in plain sight: every error response across every route is { error: "SomeCode" }, sometimes with a fields array added for validation failures. A client written against Linkstash can check res.body.error on any non-2xx response and know it'll be a string, every time, instead of guessing whether this particular route returns { message: ... } or { err: ... } or a bare string. Small, boring, and it's the difference between an API that's pleasant to build a client against and one that requires reading the source for every single endpoint.
Quick check
Why does Linkstash return 404, not 403, when you request a link that belongs to another user?
Next up: error handling and the async trap, where the last unhandled outcome, a route that throws, gets a response shape of its own.

Written by
Rhythm Bhiwani
Engineer and relentless builder, happiest reverse-engineering hard problems until they click.
Enjoyed this?
Tap the heart to leave some love.
Be the first to react
Comments
Join the conversation.
Loading comments…


