Skip to main content

πŸš€ Building RESTful APIs with Express

An API is the contract between your frontend and everything behind it. In this lesson you'll design a clean RESTful API in Express β€” mapping HTTP methods to CRUD, naming resources sensibly, returning the right status codes, and adding the production touches (pagination, versioning, docs) that separate a toy endpoint from a real service.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the core REST principles and why they dominate web APIs
  • Map HTTP methods (GET, POST, PUT, PATCH, DELETE) to CRUD operations and the correct status codes
  • Design resource-oriented URLs, including nested/hierarchical resources
  • Return consistent response envelopes and implement pagination, filtering, and sorting
  • Apply API versioning with routers and document endpoints with OpenAPI/Swagger

Estimated Time: 45–60 minutes  β€’  Difficulty: Intermediate

Hands-on: Build a paginated, filterable /api/products endpoint and a nested resource route.

In This Lesson

What REST Actually Means

REST (Representational State Transfer) is an architectural style for networked applications. It isn't a library or a protocol you install β€” it's a set of conventions layered on top of plain HTTP. When an API follows those conventions, any developer who knows HTTP can guess how it works without reading much documentation. That predictability is the whole point.

Express is a thin, unopinionated web framework for Node.js. It doesn't force REST on you, but it makes REST easy: a route is a method plus a path plus a handler, and that maps almost one-to-one onto REST's "verb acts on a resource" model.

Client, Express API, and database request cycle A client sends an HTTP request to an Express API, which performs CRUD operations against a database and returns an HTTP response. Client browser Β· mobile Β· IoT Express API routes Β· handlers validation Β· logic Database SQL Β· NoSQL request response query rows
Figure 1 β€” The API sits between clients and data. Its job is to expose data as resources that clients manipulate with standard HTTP verbs.
🍽️ The restaurant analogy. A RESTful API is like a restaurant. The resources (users, products) are menu items. The HTTP methods are how you place your order β€” "bring me" (GET), "add this" (POST), "change my order" (PUT/PATCH), "cancel it" (DELETE). The routes are sections of the menu, and the responses are what the waiter brings back. Because every restaurant follows the same ordering ritual, you can walk into a new one and know what to do.

REST Architectural Principles

REST is defined by a handful of constraints. You don't have to memorize the theory, but understanding each one explains why good APIs are shaped the way they are:

ConstraintWhat it meansWhy it matters
Resource-basedEverything is a resource with a unique URI (/users/42)URLs stay predictable and meaningful
StatelessEach request carries everything needed to process itAny server instance can handle any request β€” easy scaling
Uniform interfaceThe same small set of verbs acts on every resourceClients reuse knowledge across endpoints
Client–serverFrontend and backend evolve independentlySeparation of concerns
Layered systemProxies, caches, and gateways can sit in between invisiblySecurity and performance without client changes
CacheableResponses state whether they can be cachedFewer round-trips, faster apps

βœ… Why REST dominates

REST won because it's simple (built on HTTP you already know), scalable (statelessness makes horizontal scaling trivial), and platform-independent (any client that speaks HTTP can talk to it). GitHub, Stripe, Twilio, and countless others expose REST as their primary developer interface.

HTTP Methods & CRUD

The heart of REST is the mapping between HTTP methods and the four data operations β€” Create, Read, Update, Delete. Learn this table and most of REST falls into place:

MethodCRUDDescriptionExampleSuccess code
GETReadRetrieve a resource; never modifies data (safe)GET /users/42200 OK
POSTCreateCreate a new resource in a collectionPOST /users201 Created
PUTUpdateReplace a resource entirelyPUT /users/42200 OK
PATCHUpdatePartially modify a resourcePATCH /users/42200 OK
DELETEDeleteRemove a resourceDELETE /users/42204 No Content

πŸ“– Safe vs. idempotent

Safe methods (GET) never change server state. Idempotent methods produce the same result no matter how many times you repeat them: GET, PUT, and DELETE are idempotent; POST is not (calling it twice creates two resources). PATCH may or may not be idempotent depending on how you write it. These properties let caches and retry logic behave correctly.

The difference between PUT and PATCH trips up beginners: PUT replaces the whole resource (any field you omit gets cleared), while PATCH applies only the fields you send. Use PATCH for "update one thing", PUT for "here is the complete new version".

A Complete CRUD API

Here is a full CRUD API for a users resource. It uses an in-memory array so you can run it immediately with just npm install express β€” in a real app the array would be a database. The code targets modern Express and uses const/let and arrow functions throughout.

const express = require('express');
const app = express();

// Parse JSON request bodies (built into Express β€” no body-parser needed)
app.use(express.json());

// In-memory "database"
let users = [
  { id: 1, name: 'Alice', email: 'alice@example.com' },
  { id: 2, name: 'Bob', email: 'bob@example.com' },
];
let nextId = 3;

// READ β€” all users
app.get('/api/users', (req, res) => {
  res.status(200).json(users);
});

// READ β€” one user
app.get('/api/users/:id', (req, res) => {
  const user = users.find((u) => u.id === Number(req.params.id));
  if (!user) {
    return res.status(404).json({ message: 'User not found' });
  }
  res.status(200).json(user);
});

// CREATE
app.post('/api/users', (req, res) => {
  const { name, email } = req.body;
  if (!name || !email) {
    return res.status(400).json({ message: 'Name and email are required' });
  }
  const newUser = { id: nextId++, name, email };
  users.push(newUser);
  // 201 Created + a Location header pointing at the new resource
  res.status(201).location(`/api/users/${newUser.id}`).json(newUser);
});

// UPDATE (full replace)
app.put('/api/users/:id', (req, res) => {
  const id = Number(req.params.id);
  const index = users.findIndex((u) => u.id === id);
  if (index === -1) {
    return res.status(404).json({ message: 'User not found' });
  }
  const { name, email } = req.body;
  if (!name || !email) {
    return res.status(400).json({ message: 'PUT requires the full resource' });
  }
  users[index] = { id, name, email };
  res.status(200).json(users[index]);
});

// UPDATE (partial)
app.patch('/api/users/:id', (req, res) => {
  const id = Number(req.params.id);
  const index = users.findIndex((u) => u.id === id);
  if (index === -1) {
    return res.status(404).json({ message: 'User not found' });
  }
  users[index] = { ...users[index], ...req.body, id }; // keep id immutable
  res.status(200).json(users[index]);
});

// DELETE
app.delete('/api/users/:id', (req, res) => {
  const id = Number(req.params.id);
  const index = users.findIndex((u) => u.id === id);
  if (index === -1) {
    return res.status(404).json({ message: 'User not found' });
  }
  users.splice(index, 1);
  res.status(204).send(); // 204 = success, no body
});

app.listen(3000, () => {
  console.log('API server running at http://localhost:3000');
});

Notice the repeating shape: every handler validates input, checks existence, mutates data, and returns a resource plus a status code. The flowchart below shows how a single router fans out into those handlers:

flowchart TD A[Express App] --> B[Router: /api/users] B --> C1[GET /] B --> C2[POST /] B --> C3[GET /:id] B --> C4[PUT /:id] B --> C5[PATCH /:id] B --> C6[DELETE /:id] C1 --> D[(Data layer)] C2 --> D C3 --> D C4 --> D C5 --> D C6 --> D

⚠️ Router, not a pile of app.get

In real projects, move these handlers into an express.Router() in a separate file (routes/users.js) and mount it with app.use('/api/users', usersRouter). It keeps app.js tiny and makes each resource independently testable. We build exactly this in the exercise.

Resource Naming & Nesting

Good URL design makes an API feel intuitive. A few rules cover almost every case:

  • Use nouns, not verbs. /users, not /getUsers. The HTTP method already is the verb.
  • Use plural collections. /users and /users/42, not /user/42.
  • Nest to show relationships. /users/42/posts reads as "posts belonging to user 42".
  • Keep it shallow and predictable. Prefer /users/42 over /user-management/find/42.
  • Use query params for filtering/sorting, not new paths: /products?category=electronics&sort=price.
  • Hyphenate multi-word resources. /blog-posts reads better than /blogposts.

Nested resources are just routes with two parameters. Here's a user's posts:

const express = require('express');
// mergeParams lets this router see :userId from the parent route
const router = express.Router({ mergeParams: true });

// GET /api/users/:userId/posts
router.get('/', (req, res) => {
  const userId = Number(req.params.userId);
  const userPosts = posts.filter((p) => p.userId === userId);
  res.status(200).json(userPosts);
});

// POST /api/users/:userId/posts
router.post('/', (req, res) => {
  const userId = Number(req.params.userId);
  if (!users.some((u) => u.id === userId)) {
    return res.status(404).json({ message: 'User not found' });
  }
  const { title, content } = req.body;
  if (!title || !content) {
    return res.status(400).json({ message: 'Title and content are required' });
  }
  const newPost = {
    id: posts.length + 1,
    userId,
    title,
    content,
    createdAt: new Date().toISOString(),
  };
  posts.push(newPost);
  res.status(201).json(newPost);
});

module.exports = router;
// Mounted in app.js:  app.use('/api/users/:userId/posts', postsRouter);

πŸ’‘ How the pros name things

These aren't arbitrary rules β€” they mirror real APIs. GitHub uses /repos/{owner}/{repo}/issues, Stripe uses /v1/customers/{id}/sources, and Spotify uses /v1/artists/{id}/albums. Every one is a plural noun collection nested under its parent.

Responses & Status Codes

Two things make an API pleasant to consume: the right status code and a consistent response shape. Clients write code against both, so changing them later breaks integrations.

The status codes you'll actually use

RangeCodeMeaning
2xx success200 OKSuccessful GET, PUT, PATCH
201 CreatedSuccessful POST
204 No ContentSuccessful DELETE (empty body)
4xx client error400 Bad RequestMalformed or invalid data
401 UnauthorizedNot authenticated
403 ForbiddenAuthenticated but not allowed
404 Not FoundResource doesn't exist
409 / 422Conflict / validation failure
5xx server error500 / 503Something broke on the server

A consistent response envelope

Wrap every response in the same structure so clients never have to guess. Small helper functions keep it DRY:

// util/apiResponse.js
const sendSuccess = (res, statusCode, data, meta = {}) =>
  res.status(statusCode).json({ success: true, data, meta });

const sendError = (res, statusCode, message, details = null) => {
  const body = { success: false, error: { message } };
  if (details) body.error.details = details;
  return res.status(statusCode).json(body);
};

module.exports = { sendSuccess, sendError };
const { sendSuccess, sendError } = require('./util/apiResponse');

app.post('/api/users', (req, res) => {
  const { name, email } = req.body;
  const errors = [];
  if (!name) errors.push({ field: 'name', message: 'Name is required' });
  if (!email) errors.push({ field: 'email', message: 'Email is required' });
  if (errors.length) {
    return sendError(res, 400, 'Validation failed', errors);
  }
  const newUser = { id: nextId++, name, email, createdAt: new Date().toISOString() };
  users.push(newUser);
  return sendSuccess(res, 201, newUser);
});

A successful response looks like:

{
  "success": true,
  "data": { "id": 3, "name": "Carol", "email": "carol@example.com" },
  "meta": {}
}

Pagination, Filtering & Sorting

Returning ten thousand rows in one response is slow and wasteful. Real collections support pagination (limit how many come back), filtering (narrow the set), and sorting β€” all through query parameters. Here's a single endpoint that does all three:

// GET /api/products?category=electronics&minPrice=10&sort=price&order=desc&page=2&limit=20
app.get('/api/products', (req, res) => {
  let result = [...products];

  // --- Filtering ---
  const { category, minPrice, maxPrice, search } = req.query;
  if (category) result = result.filter((p) => p.category === category);
  if (minPrice) result = result.filter((p) => p.price >= Number(minPrice));
  if (maxPrice) result = result.filter((p) => p.price <= Number(maxPrice));
  if (search) {
    const term = search.toLowerCase();
    result = result.filter((p) => p.name.toLowerCase().includes(term));
  }

  // --- Sorting ---
  if (req.query.sort) {
    const field = req.query.sort;
    const dir = req.query.order === 'desc' ? -1 : 1;
    result.sort((a, b) => (a[field] > b[field] ? dir : a[field] < b[field] ? -dir : 0));
  }

  // --- Pagination ---
  const page = Math.max(1, Number(req.query.page) || 1);
  const limit = Math.min(100, Number(req.query.limit) || 10); // cap the limit!
  const total = result.length;
  const start = (page - 1) * limit;
  const pageItems = result.slice(start, start + limit);

  res.status(200).json({
    success: true,
    data: pageItems,
    meta: {
      total,
      page,
      limit,
      pages: Math.ceil(total / limit),
    },
  });
});

⚠️ Always cap limit

Never trust the client to ask for a sane page size. If someone sends ?limit=999999 you'll ship your whole database in one response. The Math.min(100, ...) above is a cheap, essential guardrail.

The parameter names above aren't invented β€” they're conventions shared across production APIs:

ParameterPurposeExample
page, limitPagination?page=2&limit=20
sort, orderSorting?sort=price&order=desc
fieldsSparse fieldsets?fields=id,name,price
search, qFull-text search?search=wireless

Versioning & Documentation

Once real clients depend on your API, you can't change its shape without warning β€” you'd break their apps. Versioning lets you ship a new design while the old one keeps working.

Versioning approaches

  • URL path (most common): /api/v1/users β€” obvious and easy to route
  • Query parameter: /api/users?version=1
  • Header / content negotiation: Accept: application/vnd.company.v1+json

URL versioning is trivial in Express β€” each version is just a router mounted at a different prefix:

const express = require('express');
const app = express();

const v1Users = require('./routes/v1/users');
const v2Users = require('./routes/v2/users');

app.use('/api/v1/users', v1Users);
app.use('/api/v2/users', v2Users);

// routes/v2/users.js β€” v2 returns a richer payload than v1
const router = express.Router();
router.get('/', (req, res) => {
  const data = users.map((u) => ({
    id: u.id,
    name: u.name,
    email: u.email,
    role: u.role,
    createdAt: u.createdAt,
  }));
  res.status(200).json({ data, meta: { total: data.length, version: 'v2' } });
});
module.exports = router;

Documenting with OpenAPI / Swagger

An undocumented API is hard to adopt. OpenAPI (formerly Swagger) is the standard description format; swagger-ui-express turns it into an interactive, testable web page.

// npm install swagger-jsdoc swagger-ui-express
const swaggerJsdoc = require('swagger-jsdoc');
const swaggerUi = require('swagger-ui-express');

const spec = swaggerJsdoc({
  definition: {
    openapi: '3.0.0',
    info: { title: 'User API', version: '1.0.0' },
    servers: [{ url: 'http://localhost:3000' }],
  },
  apis: ['./routes/*.js'], // read JSDoc @swagger comments from these files
});

app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(spec));

/**
 * @swagger
 * /api/users:
 *   get:
 *     summary: Returns a paginated list of users
 *     parameters:
 *       - in: query
 *         name: page
 *         schema: { type: integer, default: 1 }
 *     responses:
 *       200:
 *         description: A list of users
 */
app.get('/api/users', (req, res) => { /* ... */ });

βœ… Docs pay for themselves

Good documentation reduces onboarding time, cuts support requests, and can even auto-generate client libraries from the OpenAPI spec. Stripe and Twilio are famous partly because of their docs.

Hands-on Exercise

πŸ‹οΈ Build a Products API with a Router

Objective: Create a clean, paginated /api/products resource in its own router file, then add a nested reviews route.

Instructions:

  1. Scaffold a project: npm init -y && npm install express.
  2. In routes/products.js, create an express.Router() with full CRUD for products (each product has id, name, price, category).
  3. Make GET / support ?category=, ?sort=price&order=desc, and ?page=&limit= β€” and cap limit at 100.
  4. Return every response in the { success, data, meta } envelope.
  5. Add a nested router at /api/products/:productId/reviews (use mergeParams: true).
  6. Mount both routers in app.js and test with curl or your browser.
πŸ’‘ Hint

Mount the nested router inside the products router, or mount it separately in app.js with the full path. Remember express.json() must run before any handler that reads req.body, and return 404 from the reviews route if the parent product id doesn't exist.

βœ… Example solution (routes/products.js)
const express = require('express');
const router = express.Router();

let products = [
  { id: 1, name: 'Wireless Mouse', price: 25, category: 'electronics' },
  { id: 2, name: 'Desk Lamp', price: 40, category: 'home' },
];
let nextId = 3;

router.get('/', (req, res) => {
  let result = [...products];
  if (req.query.category) {
    result = result.filter((p) => p.category === req.query.category);
  }
  if (req.query.sort) {
    const field = req.query.sort;
    const dir = req.query.order === 'desc' ? -1 : 1;
    result.sort((a, b) => (a[field] > b[field] ? dir : a[field] < b[field] ? -dir : 0));
  }
  const page = Math.max(1, Number(req.query.page) || 1);
  const limit = Math.min(100, Number(req.query.limit) || 10);
  const total = result.length;
  const data = result.slice((page - 1) * limit, page * limit);
  res.json({ success: true, data, meta: { total, page, limit, pages: Math.ceil(total / limit) } });
});

router.post('/', (req, res) => {
  const { name, price, category } = req.body;
  if (!name || price == null) {
    return res.status(400).json({ success: false, error: { message: 'name and price are required' } });
  }
  const product = { id: nextId++, name, price, category: category || 'uncategorized' };
  products.push(product);
  res.status(201).json({ success: true, data: product });
});

router.get('/:id', (req, res) => {
  const product = products.find((p) => p.id === Number(req.params.id));
  if (!product) return res.status(404).json({ success: false, error: { message: 'Not found' } });
  res.json({ success: true, data: product });
});

router.delete('/:id', (req, res) => {
  const i = products.findIndex((p) => p.id === Number(req.params.id));
  if (i === -1) return res.status(404).json({ success: false, error: { message: 'Not found' } });
  products.splice(i, 1);
  res.status(204).send();
});

module.exports = router;
// app.js
const express = require('express');
const app = express();
app.use(express.json());
app.use('/api/products', require('./routes/products'));
app.listen(3000, () => console.log('http://localhost:3000'));

🎯 Quick Quiz

Question 1: A client sends POST /api/users and a user is created successfully. Which status code should you return?

Question 2: What is the key difference between PUT and PATCH?

Question 3: Which URL best follows REST naming conventions for "all reviews of product 7"?

Best Practices

βœ… Do

  • Use plural nouns and nest resources to show relationships
  • Return the correct status code and a consistent response envelope
  • Validate input early and return 400/422 with helpful details
  • Paginate every collection and cap the page size
  • Version your API before the first external client depends on it
  • Split resources into express.Router() files

⚠️ Don't

  • Don't put verbs in URLs (/createUser) β€” the method is the verb
  • Don't return 200 for everything, including errors
  • Don't trust client-supplied limit without a ceiling
  • Don't leak stack traces or internal messages in production responses
  • Don't break existing response shapes β€” add a new version instead

Summary & Quiz

πŸŽ‰ Key Takeaways

  • REST is a set of HTTP conventions that make APIs predictable: resources have URIs, verbs act on them, requests are stateless.
  • HTTP methods map to CRUD β€” GET/POST/PUT/PATCH/DELETE β€” each with its own success status code.
  • Name resources as plural nouns and nest them to express relationships.
  • Consistent responses plus correct status codes are the API's contract with its clients.
  • Pagination, versioning, and docs turn a demo endpoint into a production service.

πŸ“š Further Reading

πŸš€ What's Next?

Your routes are getting repetitive β€” validating input, logging, checking auth. That cross-cutting logic belongs in middleware. Next, Custom Middleware Development shows you how to build reusable middleware pipelines that keep your handlers clean.

πŸŽ‰ Great work!

You can now design and build a real RESTful API. Let's make it maintainable with middleware.