Skip to main content
Backend & APIs
⚙️ Backend & APIsLesson 3 of 13

Designing a REST API

Design a REST API with clean, predictable resources other developers can guess.

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:

ShapeURL patternWhat it represents
Collection/usersAll users (or a filtered page of them)
Item/users/:idOne specific user
Nested collection/users/:id/ordersAll orders belonging to that user
Nested item/users/:id/orders/:orderIdOne 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:

MethodCRUD equivalentSafe?Idempotent?Typical use
GETReadYesYesFetch one or many
POSTCreateNoNoCreate a new resource
PUTReplaceNoYesFull replacement of an item
PATCHUpdateNoNoPartial update of an item
DELETEDeleteNoYesRemove 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.

MethodEndpointDescription
GET/productsList all products (pageable)
POST/productsCreate a product
GET/products/:idGet one product
PATCH/products/:idUpdate a product
DELETE/products/:idDelete a product
GET/ordersList orders (current user's, or all for admin)
POST/ordersPlace a new order
GET/orders/:idGet one order
PATCH/orders/:idUpdate order status
GET/orders/:id/itemsLine items on an order
POST/orders/:id/itemsAdd an item to an order
DELETE/orders/:id/items/:itemIdRemove an item
GET/users/:idGet a user profile
PATCH/users/:idUpdate profile
GET/users/:id/addressesShipping addresses
POST/users/:id/addressesAdd 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 /user or /order.
  • Always lowercase, always kebab-case for multi-word: /order-items, not /orderItems or /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.

  1. List every "thing" your app manages (users, tasks, recipes, products, reviews…). Those are your resources.
  2. For each resource, write out the collection URL and item URL.
  3. For each URL, decide which HTTP methods apply and what each one does.
  4. Add any nesting that makes sense (e.g., /recipes/:id/ingredients).
  5. 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. 🏛️

Always-on rigor toolkit

🏛️ Build It Right, Or Don't Build It At All.