Explorer
Node.js

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

  1. Resources, not actions. URLs represent things (nouns), not operations (verbs). The HTTP method provides the verb.
  2. Standard HTTP methods. Use GET, POST, PUT, PATCH, DELETE consistently.
  3. Stateless. Every request contains all the information the server needs. The server doesn't remember previous requests.
  4. Consistent URL patterns. Once you learn one endpoint, you can predict all the others.
  5. 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.

TEXT
❌ 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

TEXT
❌ 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

TEXT
❌ 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

TEXT
❌ 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

TEXT
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:

JAVASCRIPT
// 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;
JAVASCRIPT
// 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:

TEXT
/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:

TEXT
/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:

TEXT
/api/users/123/orders    ← "Show me all orders for user 123"
/api/orders/456          ← "Show me order 456 directly"

Avoid Deep Nesting

TEXT
❌ 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

TEXT
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:

JAVASCRIPT
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:

TEXT
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:

JAVASCRIPT
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:

TEXT
GET /api/users?search=alice
GET /api/products?q=wireless+headphones
JAVASCRIPT
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:

TEXT
GET /api/users?fields=name,email        → Only return name and email
GET /api/users?fields=-password,-__v     → Return everything EXCEPT password and __v
JAVASCRIPT
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)

TEXT
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:

JAVASCRIPT
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:

JSON
{
  "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":

TEXT
GET /api/users?limit=20                           → First 20 users
GET /api/users?limit=20&after=65bb0a38e820...     → Next 20 after this ID
JAVASCRIPT
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.

Success:

JSON
{
  "success": true,
  "data": { ... },
  "meta": { ... }
}

Error:

JSON
{
  "success": false,
  "error": {
    "message": "User not found",
    "code": "RESOURCE_NOT_FOUND"
  }
}

Paginated Success:

JSON
{
  "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:

JAVASCRIPT
// ❌ 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:

JAVASCRIPT
// ✅ 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:

JSON
{
  "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:

JAVASCRIPT
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)

TEXT
/api/v1/users       ← Original version
/api/v2/users       ← New version with breaking changes

In Express:

JAVASCRIPT
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

TEXT
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

TEXT
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

TEXT
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)

TEXT
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

TEXT
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

TEXT
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

TEXT
❌ 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

JAVASCRIPT
// ❌ 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

JSON
// ❌ 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"
}
JSON
// ✅ 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

TEXT
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:

JAVASCRIPT
const safeLimit = Math.min(parseInt(req.query.limit) || 20, 100);

Mistake 5: Using 200 + error message instead of proper status codes

JAVASCRIPT
// ❌ 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:

TEXT
□ 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:

  1. REST is about treating everything as a resource, identified by a URL, operated on with standard HTTP methods.
  2. URL design uses plural nouns, lowercase with hyphens, namespaced under /api.
  3. Nested resources express parent-child relationships, but should never go deeper than 2 levels.
  4. Query parameters handle filtering, sorting, searching, and field selection.
  5. Pagination prevents your API from drowning in data—offset-based for dashboards, cursor-based for infinite scroll.
  6. Response envelopes give your API a consistent shape that frontends can rely on.
  7. 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.

Finished this lesson?

Mark this chapter complete to update your learning streak and unlock the next lesson.