Designing a REST API
Stage 3 · Backend & APIs · B.U.I.L.D. letter: I
The URL is a contract — design it so a stranger can guess every endpoint before you write a single line of code.
⚠️ The vibe trap
When you ask an AI to "add an endpoint to delete a user," it might give you POST /deleteUser, GET /removeAccount, or DELETE /user/remove — all on the same day, in the same codebase. Each one works, and none of them agrees. Three months later, your frontend team is memorizing a random list of magic strings instead of understanding a system. Good API design is the difference between a system that teaches itself and one that needs a cheat sheet.
📦 Resources are nouns, not verbs
The single biggest REST insight: your URLs name things (resources), not actions. The HTTP method carries the action.
Bad (RPC-style):
GET /getUser?id=5
POST /createPost
POST /deleteComment?id=12
POST /updateUserProfile
Good (REST-style):
GET /users/5
POST /posts
DELETE /comments/12
PATCH /users/5
The verb is already in the method. Saying GET /getUser is like writing "fetch GET /users" — redundant and noisy.
Mental model: Imagine your API is a filing cabinet. Each drawer is a resource (/users, /posts, /orders). You don't label the drawer "getUsers" — you label it "users" and then decide what you want to do with it (open it, add to it, remove from it).
Common mistake: Keeping old habits from function naming. If you find yourself writing /fetchProducts or /submitOrder, stop — those are function names. Turn them into GET /products and POST /orders.
🗂️ URL structure: collections, items, and nesting
Every resource has two canonical shapes:
| Shape | URL pattern | What it represents |
|---|---|---|
| Collection | /users | All users (or a filtered page of them) |
| Item | /users/:id | One specific user |
| Nested collection | /users/:id/orders | All orders belonging to that user |
| Nested item | /users/:id/orders/:orderId | One specific order for that user |
Nesting expresses ownership or containment — use it when the child resource only makes sense in the context of the parent. Don't nest for the sake of it; two levels deep is almost always enough.
// Express — a blog API
app.get('/posts', getPosts); // list all posts
app.post('/posts', createPost); // create a post
app.get('/posts/:postId', getPost); // get one post
app.patch('/posts/:postId', updatePost); // partial update
app.delete('/posts/:postId', deletePost); // delete
app.get('/posts/:postId/comments', getComments); // comments on a post
app.post('/posts/:postId/comments', createComment);
app.delete('/posts/:postId/comments/:id', deleteComment);
Mental model: Read the URL aloud. /posts/42/comments = "the comments on post 42." If you can't say it that naturally, rethink the nesting.
Common mistake: Nesting everything. /users/:id/posts/:postId/comments/:id/likes is a URL, not an address. If you need the likes on a comment, consider a flat /likes?commentId= query param once nesting goes past two levels.
🔄 Mapping HTTP methods to CRUD
HTTP gives you a small vocabulary. Use it consistently:
| Method | CRUD equivalent | Safe? | Idempotent? | Typical use |
|---|---|---|---|---|
| GET | Read | Yes | Yes | Fetch one or many |
| POST | Create | No | No | Create a new resource |
| PUT | Replace | No | Yes | Full replacement of an item |
| PATCH | Update | No | No | Partial update of an item |
| DELETE | Delete | No | Yes | Remove a resource |
"Safe" means the request has no side effects. "Idempotent" means calling it 10 times has the same result as calling it once — important for retries.
// Full CRUD for a store's /products resource
app.get('/products', listProducts); // GET — safe, idempotent
app.post('/products', createProduct); // POST — not safe, not idempotent
app.get('/products/:id', getProduct); // GET — safe, idempotent
app.put('/products/:id', replaceProduct); // PUT — idempotent (full overwrite)
app.patch('/products/:id', updateProduct); // PATCH — partial update
app.delete('/products/:id', deleteProduct); // DELETE — idempotent
Common mistake: Using POST for everything because it "just works." It does work — right up until a retry sends a duplicate order, or a cache layer ignores your supposedly-safe fetch because it came in as a POST.
🏛️ A complete, guessable resource map (worked example)
Here is a small e-commerce API. A new developer should be able to guess every URL after seeing three of them.
| Method | Endpoint | Description |
|---|---|---|
| GET | /products | List all products (pageable) |
| POST | /products | Create a product |
| GET | /products/:id | Get one product |
| PATCH | /products/:id | Update a product |
| DELETE | /products/:id | Delete a product |
| GET | /orders | List orders (current user's, or all for admin) |
| POST | /orders | Place a new order |
| GET | /orders/:id | Get one order |
| PATCH | /orders/:id | Update order status |
| GET | /orders/:id/items | Line items on an order |
| POST | /orders/:id/items | Add an item to an order |
| DELETE | /orders/:id/items/:itemId | Remove an item |
| GET | /users/:id | Get a user profile |
| PATCH | /users/:id | Update profile |
| GET | /users/:id/addresses | Shipping addresses |
| POST | /users/:id/addresses | Add an address |
Express stubs for the order subtree:
const router = express.Router();
router.get('/', listOrders);
router.post('/', createOrder);
router.get('/:id', getOrder);
router.patch('/:id', updateOrder);
router.get('/:id/items', listOrderItems);
router.post('/:id/items', addOrderItem);
router.delete('/:id/items/:itemId', removeOrderItem);
app.use('/orders', router);
Mental model: Design the API a stranger could guess. If you show someone GET /orders/55 and GET /products/12, they should immediately know DELETE /products/12 exists without asking you. That predictability is the goal — it cuts documentation burden and onboarding time.
Naming rules that make it guessable:
- Always plural nouns:
/users,/posts,/orders— not/useror/order. - Always lowercase, always kebab-case for multi-word:
/order-items, not/orderItemsor/OrderItems. - Never include the HTTP verb in the URL: no
/getUser, no/createPost. - IDs in the path, filters as query params:
/products?category=shoes&sort=price.
🔁 Response shape consistency
The shape of your response matters as much as the URL. Pick one envelope and stick with it everywhere:
// Success — collection
{
"data": [ { "id": 1, "name": "Widget" }, { "id": 2, "name": "Gadget" } ],
"meta": { "total": 84, "page": 1, "perPage": 20 }
}
// Success — single item
{
"data": { "id": 1, "name": "Widget", "price": 9.99 }
}
// Error
{
"error": {
"code": "NOT_FOUND",
"message": "Product with id 99 does not exist."
}
}
Why this matters: if sometimes you return { user: {...} }, sometimes { data: { user: {...} } }, and sometimes just {...}, every frontend caller writes different unwrapping code. One envelope means one unwrapper — a tiny helper function that handles every response your API ever sends.
Common mistake: Returning the raw DB row directly as the response. That leaks your schema, forces a breaking change any time a column is renamed, and often includes fields (like password_hash) that should never leave the server. Always serialize explicitly.
🚦 When REST isn't the right fit
REST is great for resource-centric systems. It's awkward when:
- Actions don't map to CRUD — "send a password reset email," "merge two accounts," "kick a player from a game." These are verbs, not resources. You can model them (e.g.
POST /password-reset-requests,POST /account-merges), but it gets strained. Some teams reach for RPC-style endpoints for one-off actions and that's fine. - Tight client–server coupling with complex queries — GraphQL lets clients specify exactly what fields they need, eliminating over- and under-fetching. If your frontend teams are constantly fighting with "we get 40 fields but only need 3," consider it.
- Real-time / event streams — REST is request/response. WebSockets or Server-Sent Events are better fits for live data.
For most backend APIs you'll build in this course, REST is exactly right. Know the edges so you reach for the right tool.
🛠️ Your mission
Take the app you're building (or pick one: a task manager, a recipe site, a small marketplace). Design its full resource map before touching Express.
- List every "thing" your app manages (users, tasks, recipes, products, reviews…). Those are your resources.
- For each resource, write out the collection URL and item URL.
- For each URL, decide which HTTP methods apply and what each one does.
- Add any nesting that makes sense (e.g.,
/recipes/:id/ingredients). - Write the endpoint table in a markdown file, then stub all the routes in Express — even if the handlers just
res.json({ todo: true })for now.
✅ You're done when…
- Every URL in your resource map uses a plural noun, lowercase, kebab-case — no verbs anywhere (API Design Checklist).
- Each resource has both a collection endpoint (
/things) and an item endpoint (/things/:id), and the correct HTTP methods are assigned to each (API Design Checklist). - You can show the table to someone who hasn't seen your app and they can guess at least two endpoints you didn't tell them about.
- All responses follow a single consistent envelope shape (
{ data: ... }for success,{ error: ... }for failures). - Any nested routes go no deeper than two levels, and the nesting expresses genuine ownership (not just convenience).
➡️ Next: Request Validation & Error Handling. Build It Right, Or Don't Build It At All. 🏛️