π 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.
π½οΈ 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:
| Constraint | What it means | Why it matters |
|---|---|---|
| Resource-based | Everything is a resource with a unique URI (/users/42) | URLs stay predictable and meaningful |
| Stateless | Each request carries everything needed to process it | Any server instance can handle any request β easy scaling |
| Uniform interface | The same small set of verbs acts on every resource | Clients reuse knowledge across endpoints |
| Clientβserver | Frontend and backend evolve independently | Separation of concerns |
| Layered system | Proxies, caches, and gateways can sit in between invisibly | Security and performance without client changes |
| Cacheable | Responses state whether they can be cached | Fewer 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:
| Method | CRUD | Description | Example | Success code |
|---|---|---|---|---|
| GET | Read | Retrieve a resource; never modifies data (safe) | GET /users/42 | 200 OK |
| POST | Create | Create a new resource in a collection | POST /users | 201 Created |
| PUT | Update | Replace a resource entirely | PUT /users/42 | 200 OK |
| PATCH | Update | Partially modify a resource | PATCH /users/42 | 200 OK |
| DELETE | Delete | Remove a resource | DELETE /users/42 | 204 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:
β οΈ 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.
/usersand/users/42, not/user/42. - Nest to show relationships.
/users/42/postsreads as "posts belonging to user 42". - Keep it shallow and predictable. Prefer
/users/42over/user-management/find/42. - Use query params for filtering/sorting, not new paths:
/products?category=electronics&sort=price. - Hyphenate multi-word resources.
/blog-postsreads 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
| Range | Code | Meaning |
|---|---|---|
| 2xx success | 200 OK | Successful GET, PUT, PATCH |
| 201 Created | Successful POST | |
| 204 No Content | Successful DELETE (empty body) | |
| 4xx client error | 400 Bad Request | Malformed or invalid data |
| 401 Unauthorized | Not authenticated | |
| 403 Forbidden | Authenticated but not allowed | |
| 404 Not Found | Resource doesn't exist | |
| 409 / 422 | Conflict / validation failure | |
| 5xx server error | 500 / 503 | Something 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:
| Parameter | Purpose | Example |
|---|---|---|
page, limit | Pagination | ?page=2&limit=20 |
sort, order | Sorting | ?sort=price&order=desc |
fields | Sparse fieldsets | ?fields=id,name,price |
search, q | Full-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:
- Scaffold a project:
npm init -y && npm install express. - In
routes/products.js, create anexpress.Router()with full CRUD for products (each product hasid,name,price,category). - Make
GET /support?category=,?sort=price&order=desc, and?page=&limit=β and caplimitat 100. - Return every response in the
{ success, data, meta }envelope. - Add a nested router at
/api/products/:productId/reviews(usemergeParams: true). - Mount both routers in
app.jsand test withcurlor 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/422with 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
200for everything, including errors - Don't trust client-supplied
limitwithout 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
- Express β Routing Guide
- RESTful API Design Guidelines
- OpenAPI Specification
- Microsoft REST API Guidelines
- Stripe API Docs β a gold-standard example
π 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.