Skip to main content

πŸ—ΊοΈ 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.

graph TD A[Endpoint Design] --> B[Resource Naming] A --> C[URL Structure] A --> D[Query Parameters] A --> E[Actions] A --> F[Versioning] B --> B1[Nouns not verbs] B --> B2[Plural collections] C --> C1[Hierarchy] C --> C2[Sensible nesting] D --> D1[Filter and sort] D --> D2[Paginate]

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βœ… GoodWhy
GET /getUsersGET /usersThe method already means "read"
POST /createOrderPOST /ordersPOST already means "create"
PUT /updateProduct/9PUT /products/9PUT already means "replace"
DELETE /removeComment/4DELETE /comments/4DELETE 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
graph TD A["/users"] --> B["/users/:userId"] B --> C["/users/:userId/posts"] C --> D["/posts/:postId"] D --> E["/posts/:postId/comments"] E --> F["/comments/:commentId"]

πŸ’‘ 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:

StyleExampleBest for
Page-based?page=2&limit=20Human-facing lists with page numbers
Offset-based?offset=20&limit=20Simple slicing; same idea as page-based
Cursor-based?limit=20&after=abc123Large 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.

StrategyExampleProsCons
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 v1 from 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

πŸš€ 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.