Skip to main content

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 reads req.body, or req.body will be undefined
  • 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 in req.query, body data in req.body
  • URL resource names should be plural nouns: /users, not /user or /getUsers

Minimal working example

js
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 MethodCRUDWhen to use
GETReadRetrieve a resource or list
POSTCreateCreate a new resource
PUTUpdate (full)Replace the entire resource
PATCHUpdate (partial)Change one or a few fields
DELETEDeleteRemove 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

js
// 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

js
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

  1. Nouns in URLs, not verbs: /users, not /getUsers or /deleteUser
  2. Plural resource names: /users, not /user
  3. Version from day one: /api/v1/users
  4. Consistent JSON shape: same structure for success and error in every response
  5. 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()

js
// 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 definitions

Wrong status codes

js
// 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

js
// 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

js
// 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 zod or joi

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

js
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

js
// 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

js
// 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?