π Building RESTful APIs with Express
An API is the contract between your server and everything that talks to it β web apps, mobile apps, and other services. In this lesson you'll learn the REST style that has become the web's default and turn its principles into a real Express API: clean resource URLs, the right HTTP method for each action, honest status codes, and a tidy routes-plus-controller layout you can grow.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain the core constraints of REST and why statelessness matters
- Map CRUD operations to the correct HTTP methods and distinguish PUT from PATCH
- Design resource URIs using nouns, plurals, and nesting β and know when to reach for query parameters
- Return accurate HTTP status codes for success and error cases
- Build a working Express API with routers and controllers using
async/await
Estimated Time: 45β60 minutes β’ Difficulty: Intermediate
Hands-on: Build a small /api/tasks resource with full CRUD and correct status codes.
In This Lesson
What REST Really Means
REST (Representational State Transfer) is an architectural style β a set of constraints for designing networked applications β not a library or a protocol. When an API follows those constraints, we call it "RESTful." The big idea: model your application as a collection of resources (users, products, orders), give each one a stable address (a URL), and manipulate them with the standard verbs the web already provides.
π‘ A useful analogy: A RESTful API is a well-run library. Each book is a resource, its shelf location is the URI, and the actions are always the same β browse the catalog (GET), donate a new book (POST), replace a worn copy (PUT), correct a typo in the record (PATCH), or withdraw a title (DELETE). Because the rules never change from shelf to shelf, anyone can use the library without a manual.
REST rests on a handful of constraints. You don't have to memorize the academic list, but two of them shape almost every decision you'll make:
π The constraints that matter most day-to-day
Clientβserver: the frontend and backend evolve independently, connected only by the API contract.
Stateless: every request carries everything the server needs to fulfill it. The server keeps no memory of previous requests from that client β which is exactly what lets you run ten copies of your server behind a load balancer.
Uniform interface: resources are addressed by URI and manipulated through a small, consistent set of methods.
Cacheable & layered: responses can declare themselves cacheable, and the client can't tell whether it's talking to your server directly or through a proxy/CDN.
β οΈ Stateless does not mean "no data"
Statelessness is about connection state, not stored data. Your database still remembers everything. What the server must not do is rely on in-memory session data tied to a specific request sequence. Authentication, for example, travels on every request (a token in a header) rather than living in server memory.
Real-world example: GitHub, Stripe, and Twilio all expose RESTful APIs. GitHub addresses a repository's issues at /repos/:owner/:repo/issues β a noun-based URL you operate on with GET, POST, PATCH, and DELETE. Once you learn the pattern for one resource, every other resource in their API feels familiar.
HTTP Methods & CRUD
The four database operations β Create, Read, Update, Delete β map cleanly onto HTTP methods. This mapping is the heart of REST:
| Method | CRUD | Meaning | Example | Idempotent? |
|---|---|---|---|---|
| GET | Read | Fetch a resource without changing it | GET /api/tasks | Yes |
| POST | Create | Create a new resource in a collection | POST /api/tasks | No |
| PUT | Update | Replace a resource entirely | PUT /api/tasks/7 | Yes |
| PATCH | Update | Modify part of a resource | PATCH /api/tasks/7 | No* |
| DELETE | Delete | Remove a resource | DELETE /api/tasks/7 | Yes |
π What "idempotent" means
An operation is idempotent if calling it once has the same effect as calling it ten times. DELETE /api/tasks/7 is idempotent β after the first call the task is gone, and repeat calls just confirm it's gone. POST /api/tasks is not: each call creates another task. This matters because clients and proxies may safely retry idempotent requests after a network hiccup.
Here's the whole verb set wired up in Express. Notice how each handler does one thing and returns one clear status:
// GET β read a collection
app.get('/api/tasks', (req, res) => {
res.json(tasks);
});
// GET β read one resource
app.get('/api/tasks/:id', (req, res) => {
const task = findTaskById(req.params.id);
if (!task) return res.status(404).json({ error: 'Task not found' });
res.json(task);
});
// POST β create a resource (201 + the new object)
app.post('/api/tasks', (req, res) => {
const task = createTask(req.body);
res.status(201).json(task);
});
// PUT β replace a resource entirely
app.put('/api/tasks/:id', (req, res) => {
const task = replaceTask(req.params.id, req.body);
res.json(task);
});
// PATCH β update only the fields sent
app.patch('/api/tasks/:id', (req, res) => {
const task = updateTask(req.params.id, req.body);
res.json(task);
});
// DELETE β remove a resource (204, no body)
app.delete('/api/tasks/:id', (req, res) => {
deleteTask(req.params.id);
res.status(204).send();
});
PUT vs PATCH: the difference that trips people up
PUT replaces the whole resource β anything you omit is wiped. PATCH merges only the fields you send, leaving the rest untouched. Given a starting task { id: 7, title: "Ship", done: false, priority: "high" }:
// PUT /api/tasks/7 with body { "title": "Ship v2", "done": true }
// Result: { id: 7, title: "Ship v2", done: true, priority: null }
// ^ priority was dropped because PUT replaces everything
// PATCH /api/tasks/7 with body { "title": "Ship v2", "done": true }
// Result: { id: 7, title: "Ship v2", done: true, priority: "high" }
// ^ priority survives because PATCH only touches sent fields
Designing Resource URIs
A good URI reads like a location, not a command. The action lives in the HTTP method; the URI only says which resource. Follow a few conventions and your API becomes guessable.
β Do
- Use nouns, never verbs:
/api/users, not/api/getUsers - Use plural collection names:
/api/users, not/api/user - Keep them lowercase with hyphens for multi-word names:
/api/blog-posts - Nest to show relationships:
/api/users/:id/orders
β οΈ Avoid
/api/getUsersβ the verb belongs to GET/api/user/7β inconsistent singular/plural/api/userManagementβ abstract, not a resource/api/blogPostsβ camelCase in a URL
Path parameters vs query parameters
Use path parameters to identify a specific resource, and query parameters to filter, sort, or paginate a collection. The rule of thumb: if removing it would point you at a different thing, it's a path parameter; if it only changes which slice of the same collection you see, it's a query parameter.
// Path parameter identifies ONE task
app.get('/api/tasks/:id', getTaskById);
// Query parameters filter, sort, and paginate the SAME collection
app.get('/api/tasks', (req, res) => {
const { status, sort = 'createdAt', page = 1, limit = 10 } = req.query;
let result = tasks;
if (status) result = result.filter(t => t.status === status);
result = [...result].sort((a, b) =>
String(a[sort]).localeCompare(String(b[sort]))
);
const start = (Number(page) - 1) * Number(limit);
const pageItems = result.slice(start, start + Number(limit));
res.json({
data: pageItems,
meta: {
total: result.length,
page: Number(page),
limit: Number(limit),
pages: Math.ceil(result.length / Number(limit)),
},
});
});
π‘ Postal analogy: The path parameter is the street address β it uniquely names the destination. Query parameters are the delivery instructions ("leave at back door", "signature required"). They change how you get the result, not which building you're delivering to.
Status Codes That Tell the Truth
The status code is the first thing a client reads. Getting it right means clients (and humans debugging with the network tab) instantly understand what happened. Return the most specific accurate code β not 200 for everything.
| Code | Meaning | When to use it |
|---|---|---|
| 200 OK | Success | GET, or an update that returns the resource |
| 201 Created | Resource created | Successful POST β return the new resource |
| 204 No Content | Success, empty body | Successful DELETE, or an update returning nothing |
| 400 Bad Request | Malformed request | Missing/invalid fields, bad JSON |
| 401 Unauthorized | Not authenticated | Missing or invalid credentials |
| 403 Forbidden | Not allowed | Authenticated, but lacks permission |
| 404 Not Found | No such resource | The URI points to nothing |
| 409 Conflict | State clash | Duplicate email, version conflict |
| 422 Unprocessable | Semantic error | Well-formed but fails validation rules |
| 500 Server Error | We broke | Unhandled exception on the server |
app.post('/api/users', (req, res) => {
// 400 β the request itself is malformed
if (!req.body.email) {
return res.status(400).json({ error: 'Email is required' });
}
// 409 β a real conflict with existing state
if (userExists(req.body.email)) {
return res.status(409).json({ error: 'Email already in use' });
}
const user = createUser(req.body);
// 201 β created, and we hand back the new resource
res.status(201).json(user);
});
β οΈ The classic mistake: 200 with an error body
Returning res.status(200).json({ error: 'Not found' }) forces every client to parse the body just to learn something went wrong β and breaks retries, caching, and monitoring, all of which key off the status code. Let the code carry the outcome and the body carry the detail.
Real-world example: Stripe leans heavily on status codes β 400 for invalid parameters, 401 for bad keys, 402 for failed payments, 429 for rate limiting β each paired with a machine-readable error code. That discipline is a big part of why their API is considered a gold standard.
Structuring an Express API
A single app.js with fifty routes becomes unmaintainable fast. The standard fix is to split concerns: routers declare the URLs, controllers hold the logic, and app.js just wires everything together.
project/
βββ app.js # wiring: middleware, routes, error handler
βββ routes/
β βββ task.routes.js # URL β controller mapping
βββ controllers/
β βββ task.controller.js # request/response logic
βββ middleware/
βββ error-handler.js # centralized error responses
The router is thin β it only says which controller function handles which URL and method:
// routes/task.routes.js
const express = require('express');
const router = express.Router();
const controller = require('../controllers/task.controller');
router.get('/', controller.getAllTasks);
router.get('/:id', controller.getTaskById);
router.post('/', controller.createTask);
router.put('/:id', controller.replaceTask);
router.patch('/:id', controller.updateTask);
router.delete('/:id', controller.deleteTask);
module.exports = router;
And app.js mounts the router under a base path, adds a catch-all 404, and finishes with the error handler:
// app.js
const express = require('express');
const taskRoutes = require('./routes/task.routes');
const errorHandler = require('./middleware/error-handler');
const app = express();
app.use(express.json()); // parse JSON bodies
app.use('/api/tasks', taskRoutes); // mount the resource
// 404 for anything unmatched
app.use((req, res) => {
res.status(404).json({ error: 'Resource not found' });
});
app.use(errorHandler); // must be last
app.listen(3000, () => console.log('API on http://localhost:3000'));
π‘ Why controllers use next(error)
In an async controller, wrap the body in try/catch and pass caught errors to next(error). Express routes them to your error-handling middleware, so every route reports failures the same way instead of each one inventing its own error response. You'll build that handler out fully in the next lessons on validation and error handling.
Worked Example: A Tasks API
Let's put it together. Here's a complete controller for a tasks resource using modern async/await. It assumes a Mongoose-style Task model, but the shape is identical for any data layer.
// controllers/task.controller.js
const Task = require('../models/task.model');
// GET /api/tasks β list with pagination
exports.getAllTasks = async (req, res, next) => {
try {
const page = Number(req.query.page) || 1;
const limit = Number(req.query.limit) || 20;
const skip = (page - 1) * limit;
const [tasks, total] = await Promise.all([
Task.find().skip(skip).limit(limit),
Task.countDocuments(),
]);
res.status(200).json({
data: tasks,
meta: { page, limit, total, pages: Math.ceil(total / limit) },
});
} catch (error) {
next(error);
}
};
// GET /api/tasks/:id β read one
exports.getTaskById = async (req, res, next) => {
try {
const task = await Task.findById(req.params.id);
if (!task) return res.status(404).json({ error: 'Task not found' });
res.status(200).json({ data: task });
} catch (error) {
next(error);
}
};
// POST /api/tasks β create
exports.createTask = async (req, res, next) => {
try {
const task = await Task.create(req.body);
res.status(201).json({ data: task });
} catch (error) {
next(error);
}
};
// PUT /api/tasks/:id β full replace
exports.replaceTask = async (req, res, next) => {
try {
const task = await Task.findOneAndReplace(
{ _id: req.params.id },
req.body,
{ new: true, runValidators: true }
);
if (!task) return res.status(404).json({ error: 'Task not found' });
res.status(200).json({ data: task });
} catch (error) {
next(error);
}
};
// PATCH /api/tasks/:id β partial update
exports.updateTask = async (req, res, next) => {
try {
const task = await Task.findByIdAndUpdate(
req.params.id,
{ $set: req.body },
{ new: true, runValidators: true }
);
if (!task) return res.status(404).json({ error: 'Task not found' });
res.status(200).json({ data: task });
} catch (error) {
next(error);
}
};
// DELETE /api/tasks/:id β remove
exports.deleteTask = async (req, res, next) => {
try {
const task = await Task.findByIdAndDelete(req.params.id);
if (!task) return res.status(404).json({ error: 'Task not found' });
res.status(204).send();
} catch (error) {
next(error);
}
};
GET /api/tasks?page=1&limit=2 responds with:
{
"data": [
{ "_id": "665f...", "title": "Write lesson", "done": false },
{ "_id": "665f...", "title": "Review PR", "done": true }
],
"meta": { "page": 1, "limit": 2, "total": 17, "pages": 9 }
}
Every handler follows the same skeleton: do the work, check for "not found", return the right status. That predictability is what makes a REST API pleasant to consume.
Hands-on Exercise
ποΈ Build a Bookmarks API
Objective: Implement a full CRUD REST resource with correct methods and status codes.
Instructions:
- Create an Express app with
express.json()and an in-memory arraylet bookmarks = []. - Implement these routes for a
/api/bookmarksresource:GET /api/bookmarksβ 200 with the listGET /api/bookmarks/:idβ 200, or 404 if missingPOST /api/bookmarksβ 201 with the created bookmark; 400 ifurlis missingPATCH /api/bookmarks/:idβ 200 with the merged bookmark; 404 if missingDELETE /api/bookmarks/:idβ 204; 404 if missing
- Test each route with
curlor your REST client and confirm the status codes.
π‘ Hint
Generate ids with crypto.randomUUID(). For PATCH, find the item, then use Object.assign(existing, req.body) to merge only the sent fields. Remember to return after sending an error response so the handler doesn't keep running.
β Solution
const express = require('express');
const { randomUUID } = require('crypto');
const app = express();
app.use(express.json());
let bookmarks = [];
app.get('/api/bookmarks', (req, res) => {
res.status(200).json({ data: bookmarks });
});
app.get('/api/bookmarks/:id', (req, res) => {
const bookmark = bookmarks.find(b => b.id === req.params.id);
if (!bookmark) return res.status(404).json({ error: 'Not found' });
res.status(200).json({ data: bookmark });
});
app.post('/api/bookmarks', (req, res) => {
if (!req.body.url) {
return res.status(400).json({ error: 'url is required' });
}
const bookmark = { id: randomUUID(), ...req.body };
bookmarks.push(bookmark);
res.status(201).json({ data: bookmark });
});
app.patch('/api/bookmarks/:id', (req, res) => {
const bookmark = bookmarks.find(b => b.id === req.params.id);
if (!bookmark) return res.status(404).json({ error: 'Not found' });
Object.assign(bookmark, req.body);
res.status(200).json({ data: bookmark });
});
app.delete('/api/bookmarks/:id', (req, res) => {
const index = bookmarks.findIndex(b => b.id === req.params.id);
if (index === -1) return res.status(404).json({ error: 'Not found' });
bookmarks.splice(index, 1);
res.status(204).send();
});
app.listen(3000, () => console.log('http://localhost:3000'));
Best Practices
β Do
- Prefix all API routes with
/api(and consider/api/v1) so you can evolve them later. - Return the created/updated resource from POST/PUT/PATCH so the client doesn't need a second request.
- Use plural nouns and nest only one or two levels deep.
- Keep controllers thin β push shared logic into services/models.
- Send a consistent response envelope (e.g. always
{ data, meta }).
β Don't
- Don't put verbs in URLs (
/api/createTask) β the method is the verb. - Don't return
200for errors, and don't leak stack traces to clients in production. - Don't deeply nest (
/users/:id/orders/:oid/items/:iid/...) β flatten with top-level resources. - Don't repeat error-response code in every handler β centralize it in error-handling middleware.
π‘ A word on versioning
When a breaking change is unavoidable, version the API rather than break existing clients. The most common approach is a path prefix (/api/v1, /api/v2), each mounted on its own router. Header-based (Accept-Version) and media-type versioning exist too, but path versioning is the easiest to reason about and to cache.
Summary & Quiz
π Key Takeaways
- REST models your app as resources addressed by URIs and manipulated with standard HTTP methods.
- GET/POST/PUT/PATCH/DELETE map to Read/Create/Replace/Update/Delete; PUT replaces, PATCH merges.
- Design URIs as plural nouns; use path params to identify and query params to filter/sort/paginate.
- Return the most specific accurate status code β never 200 for errors.
- Split an Express API into routers, controllers, and middleware, and pass errors to
next().
π― Quick Quiz
Question 1: A client sends a request that omits several fields but wants the other fields left unchanged. Which method should the API use?
Question 2: Which URI best follows REST naming conventions for "the orders belonging to user 42"?
Question 3: A POST to create a resource succeeds. What status code should the response carry?
π Further Reading
π What's Next?
Your API now accepts requests β but it trusts every byte the client sends. Next we'll add request validation and sanitization so bad or malicious input never reaches your business logic.
π Well done!
You can now design and build a clean, predictable REST API in Express. That skeleton scales to almost any resource you'll ever model.