πΊοΈ API Endpoint Design
A great API feels like a well-labeled building: once you've found one room, you can guess where every other room is. In this lesson you'll turn the REST principles you've learned into concrete URL structures β naming resources, nesting relationships, filtering and paging collections, and versioning β so developers can navigate your API by intuition.
Week 7 · Day 3 (Wednesday: RESTful API Design) · Lecture 3
π― Learning Objectives
By the end of this lesson, you will be able to:
- Name resources with consistent, predictable conventions
- Structure URL hierarchies and decide when to nest vs. flatten
- Add filtering, sorting, pagination, and field selection with query parameters
- Design action endpoints for operations that don't fit CRUD
- Choose and apply an API versioning strategy
- Assemble a coherent endpoint map for a real application
Estimated Time: 65 minutes
Practice: Build a paginated, filterable Express collection endpoint and design the full endpoint map for a blog platform.
In This Lesson
Why Endpoint Design Matters
Your endpoints are the public face of your API β the part every consumer touches. Thoughtful design pays off in ways that compound: a smaller learning curve, fewer support tickets, easier documentation, and the freedom to evolve without breaking clients. Sloppy design does the opposite, and once developers have integrated against your URLs, they're expensive to change.
π The Library Catalog Analogy
Designing endpoints is like organizing a library catalog. Top-level sections (Books, Journals, Media) are your top-level resources (/users, /products, /orders). A specific book is a member (/books/123). A book's chapters are nested resources (/books/123/chapters). Consistent shelving conventions let a visitor find anything without a staff member. A well-designed API gives developers that same "I can find it myself" confidence.
Resource Naming
You met the golden rule last lesson: URLs are nouns, methods are verbs. Endpoint design is about applying that rule consistently across an entire API. Consistency is the real prize β one predictable pattern beats a dozen individually-clever ones.
Nouns, not verbs
| β Poor | β Good | Why |
|---|---|---|
GET /getUsers | GET /users | The method already means "read" |
POST /createOrder | POST /orders | POST already means "create" |
PUT /updateProduct/9 | PUT /products/9 | PUT already means "replace" |
DELETE /removeComment/4 | DELETE /comments/4 | DELETE already means "remove" |
Plural collections
Prefer plural nouns for collections β /users, not /user. It reads naturally both for the whole set (/users) and for one member (/users/123), and it's the dominant industry convention. Whatever you choose, be consistent: never mix /users with /product.
Casing and clarity
β Naming conventions that scale
- lowercase, kebab-case for multi-word paths:
/product-categories,/shopping-carts - Whole words, not abbreviations:
/customers, not/custs - Domain language: use the term your business experts use (
/invoices, not/bills) - Hide implementation:
/articles, never/article-table
# A consistent e-commerce resource vocabulary
GET /products
GET /products/123
GET /categories
GET /categories/electronics
GET /customers
GET /customers/456
GET /orders
GET /shopping-carts/abc
URL Hierarchy & Nesting
Resources relate to one another, and your URL structure should reflect those relationships β but only up to a point. The art is knowing when to nest and when to flatten.
Nested resources show ownership
GET /users/9/posts # posts BELONGING TO user 9
GET /users/9/posts/42 # a specific post by user 9
POST /users/9/posts # create a post for user 9
GET /posts/42/comments # comments ON post 42
POST /posts/42/comments # add a comment to post 42
Nesting communicates "this belongs to that." It also scopes access naturally β /users/9/posts can't accidentally return another user's posts.
Know when to flatten
Nesting is seductive, but every extra level makes URLs longer and clients more brittle. Cap nesting at two, occasionally three, levels. Beyond that, give the deep resource its own top-level home or use query parameters.
# β Too deep β painful to build, painful to read
GET /users/9/posts/42/comments/7/replies/3
# β
Flatten: expose the resource directly
GET /replies/3
GET /comments/7/replies
# β
Or scope with a query parameter
GET /replies?commentId=7
π‘ The hybrid pattern
A common, pragmatic approach: offer both a nested route for scoped listing and a flat route for direct access. GET /posts/42/comments lists a post's comments, while GET /comments/7 and PATCH /comments/7 operate on one comment directly. You get readable relationships without ever needing a five-segment URL.
Path parameters
The :id placeholders in a route are path parameters β they identify which resource. Name them descriptively (:userId, not just :id) and consistently across endpoints. In Express they arrive on req.params:
app.get('/users/:userId/posts/:postId', (req, res) => {
const { userId, postId } = req.params; // both path parameters
const post = getPost(userId, postId);
if (!post) return res.status(404).json({ error: 'Not Found' });
res.json(post);
});
Filtering, Sorting & Pagination
Path segments identify which resource; query parameters modify how a collection is returned. They never change the resource itself β they filter, order, page, and trim it. This is exactly where query strings belong (and where they shine).
Filtering
GET /products?category=electronics # one filter
GET /products?category=electronics&inStock=true # combine filters
GET /products?price_gte=100&price_lte=500 # a range with operator suffixes
GET /articles?title_contains=javascript # partial match
Sorting
GET /products?sort=price # ascending by default
GET /products?sort=-price # leading "-" means descending
GET /users?sort=lastName,firstName # multiple keys, in order
Pagination
Always paginate collections β an unbounded list is a performance incident waiting to happen. Two common styles:
| Style | Example | Best for |
|---|---|---|
| Page-based | ?page=2&limit=20 | Human-facing lists with page numbers |
| Offset-based | ?offset=20&limit=20 | Simple slicing; same idea as page-based |
| Cursor-based | ?limit=20&after=abc123 | Large or real-time datasets (stable under inserts) |
Field selection
GET /users?fields=id,name,email # return only these fields (lighter payloads)
Here's a collection handler in Express that filters, sorts, and paginates β and, crucially, returns pagination metadata so the client knows how to navigate:
app.get('/api/products', (req, res) => {
const {
category,
sort = 'name',
page = 1,
limit = 20
} = req.query;
let results = [...products];
// 1. Filter
if (category) {
results = results.filter(p => p.category === category);
}
// 2. Sort ("-price" = descending, "price" = ascending)
const desc = sort.startsWith('-');
const key = desc ? sort.slice(1) : sort;
results.sort((a, b) => (a[key] > b[key] ? 1 : -1) * (desc ? -1 : 1));
// 3. Paginate
const pageNum = parseInt(page, 10);
const perPage = Math.min(parseInt(limit, 10), 100); // cap page size!
const total = results.length;
const start = (pageNum - 1) * perPage;
const pageItems = results.slice(start, start + perPage);
// 4. Respond WITH metadata
res.json({
data: pageItems,
pagination: {
total,
page: pageNum,
perPage,
totalPages: Math.ceil(total / perPage),
hasNext: start + perPage < total,
hasPrev: pageNum > 1
}
});
});
Example response
{
"data": [ { "id": 1, "name": "Keyboard", "category": "electronics" } ],
"pagination": {
"total": 243,
"page": 2,
"perPage": 20,
"totalPages": 13,
"hasNext": true,
"hasPrev": true
}
}
β οΈ Always cap the page size
Notice Math.min(limit, 100). Without a ceiling, a client can request ?limit=1000000 and force your server to load and serialize the entire table β an easy accidental (or deliberate) denial of service. Set a sane default and a hard maximum.
Action Endpoints
Most operations map cleanly to CRUD, but some don't. "Cancel this order," "publish this post," "send a password reset" β these are operations or state transitions, not resource creations. REST has a pragmatic escape hatch for them.
First, try modeling as state
Before inventing an action endpoint, ask whether it's really just a field change. Publishing a post might simply be a PATCH:
PATCH /posts/42
{ "status": "published" }
When an action is clearer, POST to a sub-resource
If the operation has side effects beyond a simple field (charging a card, sending mail, running a workflow), a named action reads better. Use POST to a verb sub-path on the specific resource:
POST /orders/123/cancel # cancel order 123
POST /orders/123/refund # refund order 123
POST /users/9/verify-email # trigger email verification
POST /carts/abc/checkout # run the checkout workflow
π‘ The one place a verb is allowed
An action segment like /cancel is the sanctioned exception to "no verbs in URLs." The key discipline: it hangs off a real resource (/orders/123/β¦), and it's always a POST because it changes state and isn't safe. Prefer POST /orders/123/cancel over the RPC-flavored POST /cancelOrder?id=123.
Controller resources for cross-cutting operations
A few operations don't belong to any single resource β a global search, a shipping-cost calculation. Model these as standalone "controller" resources, used sparingly:
POST /search # complex search with a request body
POST /calculate-shipping # compute a quote from address + weight
Versioning
APIs evolve, but existing clients don't upgrade on your schedule. Versioning lets you ship breaking changes without breaking anyone. The cardinal rule: introduce a new version for breaking changes; never break an existing one.
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URL path | /v1/users |
Explicit, visible, trivial to test in a browser | Same resource lives at multiple URLs |
| Query param | /users?version=1 |
URL stays stable | Easy to overlook; mixes versioning with filtering |
| Header | Accept: application/vnd.api.v1+json |
Cleanest URLs; "most RESTful" | Invisible; harder to test and debug |
π Which should you pick?
URL path versioning (/api/v1/β¦) is the most popular choice for public APIs β it's obvious, easy to route, and anyone can paste it into a browser. Header versioning is more "pure" but harder for consumers to discover and test. For most teams, start with /v1 in the path from day one.
Path versioning is a natural fit for Express routers β mount a whole version under a prefix:
const express = require('express');
const app = express();
// Each version is its own router module
const usersV1 = require('./routes/v1/users');
const usersV2 = require('./routes/v2/users');
app.use('/api/v1/users', usersV1);
app.use('/api/v2/users', usersV2); // breaking changes live here
// v1 clients keep working untouched while v2 evolves independently
β Versioning habits
- Ship
v1from the very first release β retrofitting versions later is painful - Pick one strategy and apply it everywhere
- Reserve new versions for breaking changes; add backward-compatible fields freely within a version
- Keep old versions alive for a stated deprecation window, and announce timelines early
Practice & Quiz
ποΈ Exercise 1: Design a blog endpoint map
Goal: Sketch the REST endpoints for a blog with users, posts, and comments. A user writes many posts; a post has many comments. Include CRUD for each, the two nesting relationships, one action (publish a post), and a filtering example. Assume a /api/v1 prefix.
π‘ Hint
Give each resource a plural collection and a member route. Nest comments under posts for listing, but keep a flat route for editing one comment. Publishing is a state change β POST to an action sub-path or PATCH the status.
β Solution
# Posts (CRUD)
GET /api/v1/posts
POST /api/v1/posts
GET /api/v1/posts/:postId
PUT /api/v1/posts/:postId
PATCH /api/v1/posts/:postId
DELETE /api/v1/posts/:postId
# Nesting: a user's posts, a post's comments
GET /api/v1/users/:userId/posts
GET /api/v1/posts/:postId/comments
POST /api/v1/posts/:postId/comments
# Flat access to one comment
GET /api/v1/comments/:commentId
PATCH /api/v1/comments/:commentId
DELETE /api/v1/comments/:commentId
# Action + filtering
POST /api/v1/posts/:postId/publish
GET /api/v1/posts?author=9&status=published&sort=-createdAt&page=1&limit=10
ποΈ Exercise 2: Add sorting to a handler
Goal: Extend this handler so ?sort=name sorts ascending and ?sort=-name sorts descending. Default to ascending by createdAt.
app.get('/api/tasks', (req, res) => {
let results = [...tasks];
const { sort = 'createdAt' } = req.query;
// TODO: apply ascending/descending sort based on a leading "-"
res.json({ data: results });
});
β Solution
app.get('/api/tasks', (req, res) => {
let results = [...tasks];
const { sort = 'createdAt' } = req.query;
const desc = sort.startsWith('-');
const key = desc ? sort.slice(1) : sort;
results.sort((a, b) => {
if (a[key] === b[key]) return 0;
const order = a[key] > b[key] ? 1 : -1;
return desc ? -order : order;
});
res.json({ data: results });
});
A leading - flips the comparison β one clean convention for both directions.
π― Quick Quiz
Question 1: Which endpoint best lists all comments on post 42?
Question 2: Where do filtering and pagination belong?
Question 3: You need to add breaking changes to your API. What's the right move?
Best Practices & Pitfalls
β Do
- Be consistent above all β one naming and structure pattern across the whole API
- Use plural nouns and keep URLs readable and self-descriptive
- Put filtering, sorting, and paging in query parameters
- Always paginate collections and cap the maximum page size
- Version from v1 and reserve new versions for breaking changes
β Don't
- Nest resources more than 2β3 levels deep
- Put verbs in paths, except sanctioned action sub-resources (
/orders/123/cancel) - Return an unbounded collection with no pagination
- Mix singular and plural, or kebab-case and camelCase, across endpoints
- Bury filtering criteria in the URL path (
/users/active/admins)
β οΈ Filtering in the path is a trap
# β Every filter combination becomes a new route to build and document
GET /users/active/admins/sorted-by-name
# β
One route, infinite combinations, all in query parameters
GET /users?status=active&role=admin&sort=name
Query parameters compose freely; path-based filters multiply into an unmaintainable routing table.
Summary
π Key Takeaways
- Consistency is the highest virtue β predictable patterns make an API self-teaching
- Name resources with plural nouns; let HTTP methods supply the verbs
- Nest to show ownership, but flatten beyond 2β3 levels
- Query parameters handle filtering, sorting, pagination, and field selection β always paginate and cap page size
- Action endpoints (
POST /orders/123/cancel) handle operations that don't fit CRUD - Version from v1 and add new versions only for breaking changes
π Additional Resources
- restfulapi.net β REST Resource Naming Guide
- MDN β The URL interface
- Express β Routing guide
- Martin Fowler β Richardson Maturity Model
π What's Next?
You've designed the shape of an API; next you'll master the payloads that flow through it. The following lesson, Handling JSON Data, covers parsing request bodies, structuring JSON responses, validation, and the middleware that ties it together in Express.
π Excellent!
You can now lay out an entire API that developers navigate by instinct β the mark of a design they'll actually enjoy using.