Building REST API Endpoints in Node.js and Express: Routes, Controllers, and Best Practices
How I structure an Express REST API on Node 10: routes, Joi validation, thin controllers, services and correct status codes, with a runnable TaskAPI.

Once a basic Express server runs, the next question is how to build a Node.js REST API that does not turn into one 600-line index.js. Express will not answer that for you. It is a convention about where each kind of code lives, and here I write about the one I use on every project.
This Express REST API guide follows one resource from start to finish: routes, request validation, controllers, a service layer, pagination and honest status codes. If you have never started an Express server, my guide on how to start a basic server using Express covers the install and the first route. The official Express routing guide remains the reference for the router itself.
The examples run on Node 10 LTS with Express 4.16, Joi 14 and the uuid package. I build a small TaskAPI with in-memory storage, so every file runs with zero setup. The curl output below comes from running that code.
My position on any Express REST API is simple. Split the code into routes, controllers and services from the first endpoint, not “when the project gets big”. The split costs three small files. Retrofitting it later, when handlers already talk to the database directly, costs weeks.
The Express REST API project layout
The project has four folders, and each one has one job:
taskapi/
src/
server.js starts listening (the only file that opens a port)
app.js builds the Express app (importable by tests)
routes/tasks.routes.js URL + verb -> validation -> controller method
controllers/tasks.controller.js HTTP in, HTTP out
services/tasks.service.js rules and storage, no req or res
middleware/validate.js Joi schemas applied to body, query and params
The one decision worth defending is the split between server.js and app.js. The app.js file exports a function that returns an app, and it never calls listen. As a result, a test can import the app without binding a port. It also leaves the server object in server.js, which is exactly what a shutdown handler needs to close. Install the dependencies and start:
npm init -y
npm install express@4 joi@14 uuid@3
node src/server.js
The app: middleware order is the program
const express = require('express');
const { createTasksService } = require('./services/tasks.service');
const { createTasksController } = require('./controllers/tasks.controller');
const { createTasksRouter } = require('./routes/tasks.routes');
function createApp({ tasksService = createTasksService() } = {}) {
const app = express();
app.disable('x-powered-by');
app.use(express.json({ limit: '100kb' }));
app.get('/healthz', (req, res) => res.json({ status: 'ok' }));
app.use('/tasks', createTasksRouter(createTasksController(tasksService)));
return app;
}
module.exports = { createApp };
Read it from top to bottom, because Express runs it from top to bottom. The express.json() parser comes first, so req.body is already parsed when a handler runs. Since Express 4.16 this parser is built in, so you no longer need a separate body-parser install for JSON.
The limit: '100kb' is deliberate. The default is already 100kb, but writing it down makes the decision visible. Also, app.disable('x-powered-by') removes a header that tells scanners which framework to target. It costs nothing.
Notice that the service is a parameter with a default value. That single line is the whole dependency injection system a small Express app needs. Later, it lets a test pass in a service that fails on purpose.
The server file stays tiny:
const { createApp } = require('./app');
const port = Number(process.env.PORT) || 3000;
const server = createApp().listen(port, () => {
console.log(`TaskAPI listening on port ${port}`);
});
module.exports = server;
Routes: a table of contents, not logic
const express = require('express');
const Joi = require('joi');
const { validate } = require('../middleware/validate');
const title = Joi.string().trim().min(3).max(200);
const idParams = Joi.object({ id: Joi.string().guid({ version: 'uuidv4' }).required() });
const listQuery = Joi.object({
done: Joi.boolean(),
limit: Joi.number().integer().min(1).max(100).default(20),
offset: Joi.number().integer().min(0).default(0),
});
const createBody = Joi.object({ title: title.required() });
const updateBody = Joi.object({ title, done: Joi.boolean() }).min(1);
function createTasksRouter(controller) {
const router = express.Router();
router.get('/', validate({ query: listQuery }), controller.list);
router.post('/', validate({ body: createBody }), controller.create);
router.get('/:id', validate({ params: idParams }), controller.get);
router.patch('/:id', validate({ params: idParams, body: updateBody }), controller.update);
router.delete('/:id', validate({ params: idParams }), controller.remove);
return router;
}
module.exports = { createTasksRouter };
In an Express REST API, the routes file should read like a table of contents. Each line states the verb, the path, the input check and the handler. No handler code lives here. Three details deserve a closer look.
First, the query schema. Query strings are always strings, so ?limit=20 arrives as "20". Joi converts types by default, so Joi.number() turns it into a number. Then .min(1).max(100) bounds it, so a client cannot ask for a million rows. Likewise, Joi.boolean() maps the strings "true" and "false" correctly, which a hand-written Boolean(value) does not.
Second, Joi.object() rejects unknown keys by default. That is the behaviour you want for request bodies. A client that sends {"titel": "..."} gets a clear “not allowed” error. It also cannot smuggle in fields such as id or done on create. On the update schema, .min(1) turns an empty PATCH {} into a client error instead of a silent no-op.
Third, the id check. A malformed id is rejected at the edge, before any lookup runs.
Validation middleware: one place that says “no”
const Joi = require('joi');
// Validates req.body, req.query and req.params against Joi schemas.
// Converted values replace the raw ones, so handlers only see checked data.
function validate(schemas) {
return (req, res, next) => {
const issues = [];
Object.keys(schemas).forEach((part) => {
const result = Joi.validate(req[part], schemas[part], { abortEarly: false });
if (result.error) {
result.error.details.forEach((detail) => {
issues.push({ path: [part].concat(detail.path).join('.'), message: detail.message });
});
} else {
req[part] = result.value;
}
});
if (issues.length > 0) {
return res.status(400).json({ error: 'validation_failed', issues });
}
next();
};
}
module.exports = { validate };
The abortEarly: false option makes Joi report every problem at once instead of stopping at the first. After a successful check, the converted value replaces the raw one. So req.query.limit is a real number by the time the controller reads it, and defaults are filled in. The rule that follows is strict: if a field is not in a schema, the handler never sees it.
Here is what a client sees when the input is wrong:
$ curl -s -X POST localhost:3101/tasks -H 'content-type: application/json' -d '{"title":"x","extra":1}'
{"error":"validation_failed","issues":[{"path":"body.title","message":"\"title\" length must be at least 3 characters long"},{"path":"body.extra","message":"\"extra\" is not allowed"}]}
$ curl -s "localhost:3101/tasks?limit=1000"
{"error":"validation_failed","issues":[{"path":"query.limit","message":"\"limit\" must be less than or equal to 100"}]}
$ curl -s localhost:3101/tasks/abc
{"error":"validation_failed","issues":[{"path":"params.id","message":"\"id\" must be a valid GUID"}]}
Each failure lists every problem, with a path that tells the client what to fix. I keep this error shape simple on purpose. In a larger service, this middleware would pass the error to one central error handler instead of writing the response itself.
Controllers: translate HTTP, nothing else
function createTasksController(service) {
return {
list(req, res) {
const result = service.list(req.query);
res.set('X-Total-Count', String(result.total)).json(result.items);
},
create(req, res) {
const task = service.create(req.body);
res.status(201).location(`/tasks/${task.id}`).json(task);
},
get(req, res) {
const task = service.get(req.params.id);
if (!task) return res.status(404).json({ error: 'not_found' });
res.json(task);
},
update(req, res) {
const task = service.update(req.params.id, req.body);
if (!task) return res.status(404).json({ error: 'not_found' });
res.json(task);
},
remove(req, res) {
const removed = service.remove(req.params.id);
if (!removed) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
},
};
}
module.exports = { createTasksController };
A controller does three things. It reads validated input, calls the service and chooses the status code. That is all. Status codes are where an Express REST API most often loses trust, so this table is my working vocabulary. MDN’s HTTP status reference has the full list.
| Status | When | In this code |
|---|---|---|
| 200 OK | Read or update succeeded, body returned | get, update, list |
| 201 Created | A resource now exists; send Location |
create |
| 204 No Content | Success with nothing to say | remove |
| 400 Bad Request | Input failed validation | validate middleware |
| 404 Not Found | Valid id, nothing there | get, update, remove |
| 409 Conflict | Valid request that clashes with current state | added once a unique constraint exists |
The difference between 400 and 404 matters. A malformed id such as abc is a 400, because the request itself is wrong. However, a well-formed id that matches nothing is a 404, because the request is fine and the resource is absent. Clients that retry on 5xx and fix on 4xx depend on that split.
Pagination follows the same discipline. The list endpoint returns a plain array and puts the total in an X-Total-Count header. That way the body stays the resource and the metadata stays metadata.
$ curl -si -X POST localhost:3101/tasks -H 'content-type: application/json' -d '{"title":"Write the report"}'
HTTP/1.1 201 Created
Location: /tasks/b82b82b5-a11d-47aa-a5ad-1f4556262e3e
{"id":"b82b82b5-a11d-47aa-a5ad-1f4556262e3e","title":"Write the report","done":false,"createdAt":"2018-11-28T04:41:07.312Z"}
$ curl -si "localhost:3101/tasks?done=false&limit=5"
HTTP/1.1 200 OK
X-Total-Count: 1
$ curl -s -X DELETE localhost:3101/tasks/b82b82b5-a11d-47aa-a5ad-1f4556262e3e -w '[%{http_code}]'
[204]
The service layer of an Express REST API
const uuidv4 = require('uuid/v4');
function createTasksService() {
const tasks = new Map();
return {
list({ done, limit, offset }) {
let items = Array.from(tasks.values());
if (done !== undefined) items = items.filter((task) => task.done === done);
return { items: items.slice(offset, offset + limit), total: items.length };
},
get(id) {
return tasks.get(id) || null;
},
create({ title }) {
const task = { id: uuidv4(), title, done: false, createdAt: new Date().toISOString() };
tasks.set(task.id, task);
return task;
},
update(id, changes) {
const task = tasks.get(id);
if (!task) return null;
Object.assign(task, changes);
return task;
},
remove(id) {
return tasks.delete(id);
},
};
}
module.exports = { createTasksService };
The service has no req, no res and no status codes. It takes plain values and returns plain values, or null when something is absent. Because of that, the same function works from a route handler, a background job, a script or a test.
It also means that swapping the Map for a database changes this one file. A repository backed by PostgreSQL or MongoDB can take its place with the same method names. The routes and the controller stay as they are. That is the real test of whether the layers were honest. If you have not picked a database yet, my comparison of SQL vs NoSQL under real constraints covers the trade-offs.
Note what the service does not do: it does not validate. By the time create runs, the title is already trimmed and length-checked. So I validate in one place, at the edge, and trust the data everywhere behind it.
How one request moves through the layers
Take PATCH /tasks/:id with {"done": true}. First, the JSON parser fills req.body. Next, the router matches the verb and path. Then validate checks params and body and replaces them with converted values. After that, the controller calls service.update. Finally, the controller picks the status and sends the response.
Each layer of the Express REST API has one reason to change. So when something breaks, the layer tells you what kind of bug it is. A 400 points to the schema, a 404 to the lookup and a wrong value to the service.
Troubleshooting an Express REST API
| Symptom | Cause | Fix |
|---|---|---|
req.body is {} or undefined |
No express.json() before the route, or the wrong Content-Type |
Mount the parser before routers and send content-type: application/json |
express.json is not a function |
Express older than 4.16 | Upgrade Express, or use require('body-parser').json() |
?limit=5 arrives as a string |
The converted value was not written back | Assign result.value to req.query, as the middleware does |
| Malformed JSON returns an HTML page with a stack trace | The default Express error handler | Add one central error handler that answers in JSON |
| Router seems ignored and every call returns 404 | Router mounted after a catch-all, or the wrong mount path | Check mount order; app.use('/tasks', router) prefixes every route inside |
The fourth row is worth seeing, because most Express REST API tutorials skip it. A broken JSON body produces this:
$ curl -s -X POST localhost:3101/tasks -H 'content-type: application/json' -d '{bad'
<!DOCTYPE html>
<html lang="en">
...
<pre>SyntaxError: Unexpected token b in JSON at position 1<br> at JSON.parse (<anonymous>)<br> at parse (/.../node_modules/body-parser/lib/types/json.js:89:19)
That is an HTML error page from an API that otherwise speaks JSON, and it leaks a server file path. The fix is a four-argument error handler, mounted after every route, that turns any failure into a JSON response. So do not ship an API without one.
Express REST API habits I keep in production
- Thin handlers, fat services. In every Express REST API I keep controllers close to ten lines and push every rule into services. Handlers are the hardest layer to test and the easiest to bloat.
- Versionable from day one. A
/v1prefix costs oneapp.useline. Adding it after clients exist costs a migration. Header-based versioning works too, but a path prefix is easier to spot in logs. - A written contract. The Joi schemas already describe every input, so they are the natural source for a Swagger (OpenAPI) document.
- Health checks apart from business routes.
/healthzanswers “is the process alive” without touching dependencies. That is all a load balancer needs. On Kubernetes, my post on liveness vs readiness probes explains when a deeper check helps.
I have seen the same decay in more than one codebase. A handler grows a database call, then a second one, then an email send, until nobody dares to change it. Untangling that always costs more than the three files would have. So the first rule I enforce in review is that a handler never imports a database client.
When not to build it this way
- A single webhook receiver. One route that verifies a signature and queues a job does not need controllers and services. A 30-line file is the right size.
- A throwaway prototype. If the code dies in a week, three layers are ceremony. Start flat, but split it the day it survives.
- Deeply nested reads. When clients need nested data in flexible shapes, one resource per table means many round trips. A purpose-built endpoint, or GraphQL, may fit better.
Common Express REST API mistakes
- Validating inside handlers. Someone forgets it on the fifth endpoint. Put it in middleware, so a route without a schema stands out.
- Allowing unknown keys on bodies. Client typos then turn into mysteries. Keep Joi’s default and reject them.
- Returning 200 for everything. Clients end up parsing error text out of success responses.
- Mixing up 400 and 404. They are different problems, and clients retry them differently.
- Unbounded list endpoints. Without a maximum
limit, one request can return the whole table. - Calling
listeninside the app module. Tests then fight over ports. Export the app and listen fromserver.js.
FAQ
How do I structure a Node.js REST API with Express?
Split it into routes, validation middleware, controllers and services. Routes map a verb and path to a handler. Middleware validates input, controllers translate HTTP into function calls, and services hold rules and storage. Export the app from one file and call listen from another.
What is the difference between a route and a controller in Express?
A route declares which verb and path reach which function, and which middleware runs first. A controller is the function itself. It reads validated input, calls a service and sends the response.
Do I still need body-parser in Express 4?
Not for JSON or URL-encoded forms. Since Express 4.16, express.json() and express.urlencoded() are built in. You only need body-parser on older versions or for raw and text bodies with special options.
How should I validate request data in Express?
Use a schema library such as Joi in middleware that runs before the handler. Validate body, query and params, reject unknown keys, report every error at once and write the converted values back so handlers only see checked data.
Which HTTP status codes should an Express REST API return?
Use 200 for reads and updates, 201 with a Location header after creation, 204 for deletes, 400 for validation failures, 404 when a valid id matches nothing and 409 for conflicts with current state.
An Express REST API earns trust by being boring in the right places. Validation says no at the edge, handlers only translate, and services never see HTTP. Keep those three rules from the first endpoint, and the code stays easy to test, extend and hand over.
Last updated on 31 October 2026