How to build a REST API with Express.js?
REST API with Express.js is a server that maps HTTP methods and URL paths to handler functions, each one reading, creating, updating, or deleting a resource.
Theory
TL;DR
- Call
app.use(express.json())before any route that readsreq.body, orreq.bodywill beundefined - GET reads, POST creates, PUT replaces the whole object, PATCH updates specific fields, DELETE removes
- Status codes are part of the contract: 201 for created, 204 for deleted (no body), 404 for not found, 400 for bad input
- Route parameters live in
req.params, query strings inreq.query, body data inreq.body - URL resource names should be plural nouns:
/users, not/useror/getUsers
Minimal working example
const express = require('express');
const app = express();
app.use(express.json()); // without this, req.body is undefined
app.get('/users', (req, res) => {
res.json([{ id: 1, name: 'Alice' }]); // returns JSON array
});
app.post('/users', (req, res) => {
const { name } = req.body; // reads parsed JSON body
res.status(201).json({ id: 2, name }); // 201 = Created
});
app.listen(3000);express.json() parses the incoming request body as JSON and puts the result on req.body. Without that one line, POST and PUT handlers get nothing.
HTTP methods and CRUD
| HTTP Method | CRUD | When to use |
|---|---|---|
| GET | Read | Retrieve a resource or list |
| POST | Create | Create a new resource |
| PUT | Update (full) | Replace the entire resource |
| PATCH | Update (partial) | Change one or a few fields |
| DELETE | Delete | Remove a resource |
PUT replaces the whole object. If you PUT { name: 'Alice' } to a user that also has an email field, the email disappears. PATCH only touches what you send. Most real-world APIs default to PATCH for updates because clients rarely want to resend unchanged data.
Reading request data
// GET /users/42?format=json
app.get('/users/:id', (req, res) => {
req.params.id // '42' — always a string
req.query.format // 'json'
req.headers['authorization'] // Bearer token
req.method // 'GET'
req.path // '/users/42'
});
// POST /users with JSON body
app.post('/users', (req, res) => {
req.body.name // from parsed JSON
req.body.email
});req.params.id is always a string. Comparing it to a numeric ID with === never matches. Convert with Number(req.params.id) before the lookup.
Sending responses
res.json({ data }) // sends JSON, sets Content-Type automatically
res.status(201).json(data) // chain status then body
res.status(204).send() // no body — for successful DELETE
res.status(404).json({ error: 'Not found' })
res.redirect('/new-path')Express defaults every response to 200. A client reading a 200 from a POST has no signal that the resource was actually created. Set the code explicitly.
Design rules
- Nouns in URLs, not verbs:
/users, not/getUsersor/deleteUser - Plural resource names:
/users, not/user - Version from day one:
/api/v1/users - Consistent JSON shape: same structure for success and error in every response
- Let HTTP methods carry the action, not URL paths
Versioning matters more than it seems. Once you ship /api/users to a client, you cannot change the response shape without breaking them. Adding /api/v2/users lets you iterate freely while v1 stays stable.
Common mistakes
Missing express.json()
// Wrong — req.body is undefined, no error thrown
app.post('/users', (req, res) => {
const user = { ...req.body }; // {} — empty spread, data is lost
users.push(user);
res.status(201).json(user);
});
// Correct
app.use(express.json()); // add this before route definitionsWrong status codes
// Wrong — 200 on create, 200 on delete
app.post('/users', (req, res) => res.json(user));
app.delete('/users/:id', (req, res) => res.json({ ok: true }));
// Correct
app.post('/users', (req, res) => res.status(201).json(user));
app.delete('/users/:id', (req, res) => res.status(204).send());Forgetting to convert req.params.id
// Wrong — string '42' never strictly equals number 42
const user = users.find(u => u.id === req.params.id);
// Correct
const user = users.find(u => u.id === Number(req.params.id));Verbs in the URL
// Wrong
app.post('/createUser', ...);
app.get('/deleteUser/:id', ...);
// Correct
app.post('/users', ...);
app.delete('/users/:id', ...);Where you see this in production
- Express handles the HTTP layer in many Node.js backends, often alongside a database ORM
- Routes in real projects live in separate files using
express.Router() express.urlencoded()handles HTML form submissions;express.json()handles API clients- Validation happens before the database call: check required fields in the handler, or use a library like
zodorjoi
Follow-up questions
Q: What is the difference between PUT and PATCH?
A: PUT replaces the entire resource. Fields you omit get wiped. PATCH only updates the fields you include. Most teams use PATCH for updates to avoid accidentally deleting data.
Q: Why return 204 instead of 200 on DELETE?
A: 204 means success with no body. The resource no longer exists, so there is nothing to return. 200 with an empty body also works, but 204 is the accepted convention.
Q: What happens when two routes match the same path?
A: Express runs the first matching route and stops. The second one never fires unless the first handler calls next(). This matters when you stack middleware above route handlers.
Q: How do you split routes into separate files as the app grows?
A: Use express.Router(). Define routes on a router instance, export it, then app.use('/users', usersRouter) in the main file. The router handles '/' and '/:id'; the prefix comes from app.use.
Q: How would you protect a route so only authenticated users can access it?
A: Write a middleware function that checks the Authorization header, validates the token, and either calls next() or sends res.status(401).json({ error: 'Unauthorized' }). Add it before the route handler: app.get('/users', authMiddleware, handler).
Examples
Complete CRUD for a users resource
const express = require('express');
const app = express();
app.use(express.json());
let users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
];
let nextId = 3;
// GET /users — list all
app.get('/users', (req, res) => {
res.json(users);
});
// GET /users/:id — get one
app.get('/users/:id', (req, res) => {
const user = users.find(u => u.id === Number(req.params.id));
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user);
});
// POST /users — create
app.post('/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'name and email are required' });
}
const user = { id: nextId++, name, email };
users.push(user);
res.status(201).json(user);
});
// PUT /users/:id — replace
app.put('/users/:id', (req, res) => {
const index = users.findIndex(u => u.id === Number(req.params.id));
if (index === -1) return res.status(404).json({ error: 'User not found' });
users[index] = { id: Number(req.params.id), ...req.body };
res.json(users[index]);
});
// PATCH /users/:id — partial update
app.patch('/users/:id', (req, res) => {
const user = users.find(u => u.id === Number(req.params.id));
if (!user) return res.status(404).json({ error: 'User not found' });
Object.assign(user, req.body);
res.json(user);
});
// DELETE /users/:id — remove
app.delete('/users/:id', (req, res) => {
const index = users.findIndex(u => u.id === Number(req.params.id));
if (index === -1) return res.status(404).json({ error: 'User not found' });
users.splice(index, 1);
res.status(204).send();
});
app.listen(3000, () => console.log('API running on port 3000'));Each handler returns early on the not-found case. That keeps the happy path flat and avoids nested if blocks. Worth making this the default pattern from the first route you write.
Consistent response shape
// Success
res.json({ success: true, data: user });
// Validation error
res.status(400).json({ success: false, error: 'name and email are required' });
// Paginated list
res.json({
success: true,
data: users,
pagination: { page: 1, limit: 10, total: 100 }
});Clients check success, then read data or error. One consistent shape means one place in client code handles every response type.
Splitting routes with express.Router
// routes/users.js
const router = require('express').Router();
router.get('/', (req, res) => res.json(users));
router.get('/:id', (req, res) => { /* get one */ });
router.post('/', (req, res) => { /* create */ });
module.exports = router;
// app.js
const usersRouter = require('./routes/users');
app.use('/users', usersRouter);
// GET /users -> router.get('/')
// GET /users/1 -> router.get('/:id')The router file does not know its own prefix. app.use('/users', usersRouter) attaches the prefix at mount time. So inside the router you write / and /:id, not /users and /users/:id.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.