Chapter 10: REST API Design: URLs, Resources, Pagination, Filtering, and Versioning
Chapter 10: REST API Design: URLs, Resources, Pagination, Filtering, and Versioning
Why This Chapter Matters
In the previous chapter, we mastered HTTP—methods, status codes, headers, and the request-response lifecycle. Now it's time to apply that knowledge to design APIs that other developers actually enjoy working with.
A badly designed API is like a restaurant where the menu is written in random order, appetizers are listed under desserts, prices are missing, and the waiter doesn't understand your order. You can eat there, but every visit is painful.
A well-designed REST API is predictable, consistent, and self-documenting. A frontend developer should be able to guess your endpoint URLs without reading documentation. A mobile developer should be able to paginate through 50,000 records without downloading them all at once.
This chapter teaches you how to design APIs like a professional—the kind that earns praise in code reviews rather than confused Slack messages.
Part 1: What is REST?
REST stands for REpresentational State Transfer. It was defined by Roy Fielding in his 2000 PhD dissertation. It is not a library, framework, or protocol—it is a set of architectural constraints for designing web APIs.
The core idea is simple:
Everything is a Resource. You use standard HTTP methods to perform operations on Resources identified by URLs.
A resource is any thing your API manages: a user, a product, an order, a comment, a payment, a session.
The 5 Key REST Principles
- Resources, not actions. URLs represent things (nouns), not operations (verbs). The HTTP method provides the verb.
- Standard HTTP methods. Use GET, POST, PUT, PATCH, DELETE consistently.
- Stateless. Every request contains all the information the server needs. The server doesn't remember previous requests.
- Consistent URL patterns. Once you learn one endpoint, you can predict all the others.
- Appropriate status codes. Use the right code for the right situation (as we learned in Chapter 9).
Part 2: Designing Clean URLs
Rule 1: Use Nouns, Not Verbs
URLs should describe what you're accessing, not what you're doing. The HTTP method already provides the action.
❌ BAD (verbs in URLs)
GET /getUsers
POST /createUser
PUT /updateUser/123
DELETE /deleteUser/123
GET /fetchAllOrders
✅ GOOD (nouns in URLs)
GET /api/users → Get all users
POST /api/users → Create a user
GET /api/users/123 → Get user 123
PUT /api/users/123 → Replace user 123
PATCH /api/users/123 → Update user 123
DELETE /api/users/123 → Delete user 123
See the pattern? The URL stays the same (/api/users/123), and the HTTP method changes to perform different operations on it.
Rule 2: Use Plural Nouns
❌ BAD (singular)
/api/user
/api/product
/api/order
✅ GOOD (plural)
/api/users
/api/products
/api/orders
Why plural? Because /api/users represents a collection of users. Even GET /api/users/123 makes sense—you're asking for user 123 from the users collection.
Pick either singular or plural and be absolutely consistent. Most APIs in the real world use plural.
Rule 3: Use Lowercase and Hyphens
❌ BAD
/api/UserProfiles
/api/user_profiles
/api/User-Profiles
✅ GOOD
/api/user-profiles
URLs are case-sensitive on most servers. Using lowercase with hyphens (kebab-case) is the web standard and avoids confusion.
Rule 4: Use the /api Prefix
❌ BAD (mixed with frontend routes)
/users
/products
/about
✅ GOOD (clearly namespaced)
/api/users
/api/products
The /api prefix separates API routes from frontend routes. If your frontend serves /users as an HTML page, having GET /users also be an API endpoint creates collisions.
Part 3: Nested Resources — When and How
Sometimes resources belong to other resources. A user has many orders. An order has many items. How do you express this in URLs?
Basic Nesting
GET /api/users/123/orders → Get all orders for user 123
POST /api/users/123/orders → Create a new order for user 123
GET /api/users/123/orders/456 → Get order 456 for user 123
In Express:
// features/orders/order.routes.js
import { Router } from "express";
import { getUserOrders, createOrder, getOrder } from "./order.controller.js";
// mergeParams: true allows this router to access :userId from the parent
const router = Router({ mergeParams: true });
router.get("/", getUserOrders);
router.post("/", createOrder);
router.get("/:orderId", getOrder);
export default router;
// app.js
app.use("/api/users/:userId/orders", orderRoutes);
When to Nest vs. When to Keep Flat
Nest when the child resource only makes sense within the parent:
/api/users/123/orders ← Orders belong to a specific user
/api/posts/456/comments ← Comments belong to a specific post
/api/courses/789/lessons ← Lessons belong to a specific course
Keep flat when the child resource has its own identity and is frequently accessed independently:
/api/orders/456 ← Fetch any order directly by its ID
/api/comments/789 ← Fetch any comment directly
Rule of thumb: If the client frequently needs the child resource without knowing the parent, provide a flat endpoint. If the child always requires the parent context, nest it.
Many APIs provide both:
/api/users/123/orders ← "Show me all orders for user 123"
/api/orders/456 ← "Show me order 456 directly"
Avoid Deep Nesting
❌ TOO DEEP (3+ levels)
/api/users/123/orders/456/items/789/reviews/101
✅ BETTER (max 2 levels)
/api/orders/456/items
/api/items/789/reviews
Deep nesting makes URLs long, hard to read, and painful to implement. If you're going beyond 2 levels, consider flattening.
Part 4: Query Parameters — Filtering, Sorting, Searching, and Field Selection
The URL path identifies which resource. Query parameters modify how the resource is returned.
Filtering
GET /api/users?role=admin → Only admin users
GET /api/users?role=admin&status=active → Active admin users
GET /api/products?category=electronics&minPrice=100&maxPrice=500
In Express:
export async function getUsers(req, res, next) {
try {
const { role, status } = req.query;
// Build a dynamic filter object
const filter = {};
if (role) filter.role = role;
if (status) filter.status = status;
const users = await User.find(filter);
res.json({ success: true, data: users });
} catch (error) {
next(error);
}
}
Sorting
A common convention is to use sort with a field name. Prefix with - for descending:
GET /api/users?sort=name → Sort by name A→Z (ascending)
GET /api/users?sort=-createdAt → Sort by newest first (descending)
GET /api/users?sort=-role,name → Sort by role descending, then name ascending
In Express:
export async function getUsers(req, res, next) {
try {
const { sort } = req.query;
let sortObj = {};
if (sort) {
// Convert "-createdAt" into { createdAt: -1 }
sort.split(",").forEach((field) => {
if (field.startsWith("-")) {
sortObj[field.substring(1)] = -1;
} else {
sortObj[field] = 1;
}
});
}
const users = await User.find({}).sort(sortObj);
res.json({ success: true, data: users });
} catch (error) {
next(error);
}
}
Searching
For text search, use a search or q parameter:
GET /api/users?search=alice
GET /api/products?q=wireless+headphones
if (search) {
filter.name = { $regex: search, $options: "i" }; // case-insensitive
}
Field Selection (Projection)
Sometimes the client only needs a few fields, not the entire document. Use a fields parameter:
GET /api/users?fields=name,email → Only return name and email
GET /api/users?fields=-password,-__v → Return everything EXCEPT password and __v
if (fields) {
const projection = fields.split(",").join(" ");
// Mongoose: User.find({}).select("name email")
query = query.select(projection);
}
This reduces payload size and improves response times—especially on mobile networks.
Part 5: Pagination — Don't Return 50,000 Records at Once
If your database has 50,000 users and someone calls GET /api/users, you should NOT send all 50,000 records back. The response would be enormous, slow to transfer, and could crash the client's browser.
Pagination breaks large result sets into manageable pages.
Strategy 1: Offset-Based Pagination (Simple, Most Common)
GET /api/users?page=1&limit=20 → Users 1–20
GET /api/users?page=2&limit=20 → Users 21–40
GET /api/users?page=3&limit=20 → Users 41–60
In Express:
export async function getUsers(req, res, next) {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const skip = (page - 1) * limit;
// Enforce maximum limit to prevent abuse
const safeLimit = Math.min(limit, 100);
const [users, total] = await Promise.all([
User.find({}).skip(skip).limit(safeLimit).lean(),
User.countDocuments({}),
]);
res.json({
success: true,
data: users,
meta: {
page,
limit: safeLimit,
total,
totalPages: Math.ceil(total / safeLimit),
hasNextPage: page * safeLimit < total,
hasPrevPage: page > 1,
},
});
} catch (error) {
next(error);
}
}
Example Response:
{
"success": true,
"data": [
{ "id": "abc", "name": "Alice", "email": "alice@mail.com" },
{ "id": "def", "name": "Bob", "email": "bob@mail.com" }
],
"meta": {
"page": 2,
"limit": 20,
"total": 157,
"totalPages": 8,
"hasNextPage": true,
"hasPrevPage": true
}
}
The meta object tells the frontend everything it needs to build pagination controls (page numbers, "Next" button, "Previous" button).
Pros: Simple to implement, easy to understand, allows jumping to any page.
Cons: If records are inserted or deleted while the user is paginating, they might see duplicates or skip records. Performance degrades on very large datasets because skip(10000) forces the database to scan past 10,000 records.
Strategy 2: Cursor-Based Pagination (Better for Large Datasets)
Instead of "give me page 5", you say "give me the next 20 records after this ID":
GET /api/users?limit=20 → First 20 users
GET /api/users?limit=20&after=65bb0a38e820... → Next 20 after this ID
export async function getUsers(req, res, next) {
try {
const limit = Math.min(parseInt(req.query.limit) || 20, 100);
const { after } = req.query;
const filter = {};
if (after) {
filter._id = { $gt: after }; // Greater than the cursor ID
}
const users = await User.find(filter).sort({ _id: 1 }).limit(limit + 1).lean();
// If we got limit+1 results, there's a next page
const hasNextPage = users.length > limit;
if (hasNextPage) users.pop(); // Remove the extra record
const nextCursor = hasNextPage ? users[users.length - 1]._id : null;
res.json({
success: true,
data: users,
meta: {
limit,
hasNextPage,
nextCursor,
},
});
} catch (error) {
next(error);
}
}
Pros: Consistent results even when data changes. Excellent performance on large datasets (uses index scan, no skip()).
Cons: Can't jump to page 5 directly. Only supports "next" and "previous" navigation.
Which One Should You Use?
| Use Case | Strategy |
|---|---|
| Admin dashboard with page numbers | Offset-based |
| Infinite scroll feed (social media, news) | Cursor-based |
| Small datasets (< 10,000 records) | Offset-based (simpler) |
| Large datasets (> 100,000 records) | Cursor-based (faster) |
| Real-time data that changes frequently | Cursor-based (more stable) |
Part 6: Response Envelope — Consistent Response Shapes
Your API should always return responses in a predictable format. A frontend developer should never have to guess the shape of your response.
The Recommended Envelope
Success:
{
"success": true,
"data": { ... },
"meta": { ... }
}
Error:
{
"success": false,
"error": {
"message": "User not found",
"code": "RESOURCE_NOT_FOUND"
}
}
Paginated Success:
{
"success": true,
"data": [ ... ],
"meta": {
"page": 1,
"limit": 20,
"total": 157,
"totalPages": 8,
"hasNextPage": true,
"hasPrevPage": false
}
}
Why Use an Envelope?
Without an envelope, the frontend has to handle different response shapes for every endpoint:
// ❌ Without envelope: Is this an array? An object? How do I check for errors?
const response = await fetch("/api/users");
const data = await response.json();
// Is 'data' the users array? Or an error object? Who knows!
With an envelope, every response follows the same pattern:
// ✅ With envelope: Always check 'success', then read 'data' or 'error'
const response = await fetch("/api/users");
const result = await response.json();
if (result.success) {
renderUsers(result.data);
renderPagination(result.meta);
} else {
showError(result.error.message);
}
Error Codes
For programmatic error handling, include machine-readable error codes alongside human-readable messages:
{
"success": false,
"error": {
"message": "A user with this email already exists",
"code": "DUPLICATE_EMAIL"
}
}
The message is for displaying to the user. The code is for the frontend to handle specific error cases programmatically:
if (result.error.code === "DUPLICATE_EMAIL") {
highlightField("email");
showMessage("This email is already registered. Try logging in.");
}
Part 7: API Versioning — Changing Your API Without Breaking Clients
Your API is live. Mobile apps, web frontends, and third-party integrations depend on it. Now you need to make a breaking change—like renaming a field from userName to name.
If you just change it, every client that expects userName will break. This is where API versioning comes in.
Strategy 1: URL Path Versioning (Most Common)
/api/v1/users ← Original version
/api/v2/users ← New version with breaking changes
In Express:
import v1UserRoutes from "./features/users/v1/user.routes.js";
import v2UserRoutes from "./features/users/v2/user.routes.js";
app.use("/api/v1/users", v1UserRoutes);
app.use("/api/v2/users", v2UserRoutes);
Pros: Very clear. You can see the version in every URL. Easy to test. Cons: URLs change between versions.
Strategy 2: Header Versioning
GET /api/users HTTP/1.1
Accept: application/json; version=2
Pros: URLs stay clean. Cons: Harder to test (you can't just paste a URL in a browser). Less visible.
Which Should You Use?
URL path versioning is the industry standard. GitHub, Stripe, Twitter, and most major APIs use it. Use it unless you have a specific reason not to.
When Do You Create a New Version?
Only create a new version for breaking changes:
| Change Type | Breaking? | Needs New Version? |
|---|---|---|
| Adding a new field to a response | ❌ No | ❌ No |
| Adding a new optional query parameter | ❌ No | ❌ No |
| Adding a new endpoint | ❌ No | ❌ No |
| Removing a field from a response | ✅ Yes | ✅ Yes |
| Renaming a field | ✅ Yes | ✅ Yes |
| Changing a field's data type | ✅ Yes | ✅ Yes |
| Changing the URL structure | ✅ Yes | ✅ Yes |
Pro tip: Design your API carefully upfront. Good initial design means fewer breaking changes, which means fewer versions to maintain.
Part 8: Putting It All Together — A Complete API Design
Let's design a real API for a blog platform. Here is how a professional REST API looks:
Users
GET /api/v1/users → List all users (paginated)
GET /api/v1/users?role=author&sort=-createdAt → Filter + sort
POST /api/v1/users → Create a new user
GET /api/v1/users/:id → Get a specific user
PATCH /api/v1/users/:id → Update user fields
DELETE /api/v1/users/:id → Delete a user
Posts
GET /api/v1/posts → List all posts
GET /api/v1/posts?status=published&sort=-publishedAt&page=2&limit=10
POST /api/v1/posts → Create a new post
GET /api/v1/posts/:id → Get a specific post
PATCH /api/v1/posts/:id → Update a post
DELETE /api/v1/posts/:id → Delete a post
GET /api/v1/users/:userId/posts → Get all posts by a user
Comments (nested under posts)
GET /api/v1/posts/:postId/comments → List comments on a post
POST /api/v1/posts/:postId/comments → Add a comment to a post
DELETE /api/v1/comments/:id → Delete a specific comment
Authentication
POST /api/v1/auth/register → Register
POST /api/v1/auth/login → Login (returns JWT)
POST /api/v1/auth/refresh → Refresh access token
POST /api/v1/auth/logout → Logout (invalidate token)
POST /api/v1/auth/forgot-password → Send password reset email
POST /api/v1/auth/reset-password → Reset password with token
Notice: Auth endpoints use verbs like login and register. That's okay! These are actions, not resources. The "nouns not verbs" rule applies to resource endpoints, not every endpoint in the system.
Health Check
GET /api/v1/health → { "status": "ok", "uptime": 3600 }
GET /api/v1/health/ready → Check database connectivity
Part 9: Common API Design Mistakes
Mistake 1: Inconsistent naming
❌ BAD: Every endpoint follows a different convention
GET /api/Users
GET /api/get-products
GET /api/order_items
POST /api/createComment
✅ GOOD: Consistent everywhere
GET /api/users
GET /api/products
GET /api/order-items
POST /api/comments
Mistake 2: No pagination on list endpoints
// ❌ Returns ALL 50,000 users. Client crashes.
app.get("/api/users", async (req, res) => {
const users = await User.find({});
res.json(users);
});
// ✅ Always paginate list endpoints
app.get("/api/users", async (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = Math.min(parseInt(req.query.limit) || 20, 100);
// ...
});
Mistake 3: Returning database internals
// ❌ Exposes MongoDB internals, password hash, and internal version key
{
"_id": "65bb0a38e8202d6b1d1e44a2",
"__v": 0,
"password": "$2b$10$K8.O.x9X2q...",
"name": "Alice",
"email": "alice@mail.com"
}
// ✅ Clean response: renamed _id, removed sensitive fields
{
"id": "65bb0a38e8202d6b1d1e44a2",
"name": "Alice",
"email": "alice@mail.com",
"role": "member",
"createdAt": "2025-01-15T10:30:00Z"
}
Never expose passwords, internal IDs like __v, or database-specific fields in your API responses.
Mistake 4: Not enforcing a maximum limit
GET /api/users?limit=999999
Without a cap, a client can request all records in one call—bypassing the purpose of pagination entirely. Always enforce a maximum:
const safeLimit = Math.min(parseInt(req.query.limit) || 20, 100);
Mistake 5: Using 200 + error message instead of proper status codes
// ❌ Returns 200 but the body says there's an error. Confusing!
res.status(200).json({ error: "User not found" });
// ✅ Correct status code + clear error structure
res.status(404).json({
success: false,
error: { message: "User not found", code: "USER_NOT_FOUND" },
});
Quick Reference: REST API Design Checklist
Use this checklist when designing any new API:
□ URLs use nouns (not verbs): /api/users not /api/getUsers
□ Nouns are plural: /api/users not /api/user
□ URLs are lowercase with hyphens: /api/user-profiles
□ Prefixed with /api (and optionally /v1)
□ HTTP methods match operations: GET=read, POST=create, PATCH=update, DELETE=remove
□ List endpoints are paginated (with a max limit cap)
□ Filtering uses query parameters: ?status=active&role=admin
□ Sorting uses a sort parameter: ?sort=-createdAt
□ Responses follow a consistent envelope: { success, data, meta, error }
□ Errors include a machine-readable code: { code: "DUPLICATE_EMAIL" }
□ Sensitive fields (passwords, tokens) are never exposed
□ Nesting is max 2 levels deep
□ Breaking changes get a new API version (/v2/)
Summary & What Comes Next
We've learned how to design APIs that are clean, predictable, and professional:
- REST is about treating everything as a resource, identified by a URL, operated on with standard HTTP methods.
- URL design uses plural nouns, lowercase with hyphens, namespaced under
/api. - Nested resources express parent-child relationships, but should never go deeper than 2 levels.
- Query parameters handle filtering, sorting, searching, and field selection.
- Pagination prevents your API from drowning in data—offset-based for dashboards, cursor-based for infinite scroll.
- Response envelopes give your API a consistent shape that frontends can rely on.
- API versioning via URL paths (
/v1/,/v2/) protects existing clients from breaking changes.
In the next chapter, we take our REST API architecture to the persistence layer in Chapter 11: MongoDB & Mongoose Deep Dive: Schemas, Indexes, Aggregation, and Performance—connecting our endpoints to a real database, designing resilient schemas, optimizing queries with indexes, and running multi-stage aggregations.