Express REST API Tutorial: Build a CRUD API in Node.js
Building the Back End of an App
When a mobile app shows your course list or a website saves your profile, it is talking to a server through an API. Most of these APIs follow the REST style: the server exposes URLs such as /api/students, and the app sends GET, POST, PUT and DELETE requests to them, exchanging data as JSON. Express is the most widely used framework for building such servers in Node.js. It is small, flexible and easy to learn. This tutorial builds a complete Express REST API for student records, step by step: setting up a project, routes and HTTP methods, route parameters and query strings, reading JSON bodies, validation and status codes, middleware, error handling, organising routes with a Router, and testing the API from code and from the terminal. It builds on the tutorials on Node.js basics and HTTP and REST APIs.
Setting Up
Create a project folder, initialise it and install Express:
In a terminal:
mkdir students-api cd students-api npm init -y npm install express
This tutorial uses Express 5, the current major version. Express 4 works almost identically for everything shown here, apart from the handling of errors in async functions, which is noted below.
REST in Brief
An Express REST API, like any REST API, is organised around resources (here, students), each with a URL. The HTTP method says what to do with it:
- GET /api/students: list the students (optionally filtered).
- GET /api/students/2: fetch one student.
- POST /api/students: create a new student from the JSON body.
- PUT /api/students/2: update student 2.
- DELETE /api/students/2: remove student 2.
These five operations are often called CRUD: create, read, update and delete. The server replies with a status code: 200 OK, 201 Created, 204 No Content, 400 Bad Request, 404 Not Found or 500 Internal Server Error.
The Application
Save this as app.js. It is a complete Express REST API in one file. To keep the example self-contained, the data is stored in an array in memory. A real application would use a database such as MongoDB or PostgreSQL, but the routes would look the same.
// app.js
const express = require("express");
const app = express();
app.use(express.json()); // parse JSON request bodies into req.body
// Middleware: runs for every request, then passes control on with next()
app.use((req, res, next) => {
console.log(`--> ${req.method} ${req.originalUrl}`);
next();
});
let students = [
{ id: 1, name: "Ada", dept: "Physics", level: 100 },
{ id: 2, name: "Bayo", dept: "Maths", level: 200 },
{ id: 3, name: "Chidi", dept: "Physics", level: 200 },
];
let nextId = 4;
function validate(body) {
const errors = [];
if (typeof body.name !== "string" || body.name.trim() === "") errors.push("name is required");
if (typeof body.dept !== "string" || body.dept.trim() === "") errors.push("dept is required");
if (![100, 200, 300, 400, 500].includes(body.level)) errors.push("level must be 100-500");
return errors;
}
// GET /api/students?dept=Physics (list, with an optional filter)
app.get("/api/students", (req, res) => {
const { dept } = req.query;
const result = dept ? students.filter(s => s.dept === dept) : students;
res.json(result);
});
// GET /api/students/:id (one student)
app.get("/api/students/:id", (req, res) => {
const student = students.find(s => s.id === Number(req.params.id));
if (!student) return res.status(404).json({ error: "student not found" });
res.json(student);
});
// POST /api/students (create)
app.post("/api/students", (req, res) => {
const errors = validate(req.body);
if (errors.length) return res.status(400).json({ errors });
const student = { id: nextId++, name: req.body.name.trim(), dept: req.body.dept, level: req.body.level };
students.push(student);
res.status(201).json(student);
});
// PUT /api/students/:id (update)
app.put("/api/students/:id", (req, res) => {
const index = students.findIndex(s => s.id === Number(req.params.id));
if (index === -1) return res.status(404).json({ error: "student not found" });
const updated = { ...students[index], ...req.body, id: students[index].id };
const errors = validate(updated);
if (errors.length) return res.status(400).json({ errors });
students[index] = updated;
res.json(updated);
});
// DELETE /api/students/:id
app.delete("/api/students/:id", (req, res) => {
const before = students.length;
students = students.filter(s => s.id !== Number(req.params.id));
if (students.length === before) return res.status(404).json({ error: "student not found" });
res.status(204).end();
});
// A route that fails, to show the error handler
app.get("/api/crash", () => {
throw new Error("something broke");
});
// 404 for any other route
app.use((req, res) => res.status(404).json({ error: `no route for ${req.method} ${req.path}` }));
// Error-handling middleware has four parameters
app.use((err, req, res, next) => {
console.log(" error:", err.message);
res.status(500).json({ error: "internal server error" });
});
module.exports = app;
How the Pieces Work
Routes
app.get(path, handler), app.post, app.put and app.delete register a handler for a method and a path. The handler receives the request (req) and the response (res):
req.paramsholds route parameters: the:idin the path. They are always strings, which is why the code converts them withNumber().req.queryholds the query string:?dept=Physicsgivesreq.query.dept === "Physics".req.bodyholds the parsed JSON body, but only becauseexpress.json()was added.res.json(data)sends JSON,res.status(code)sets the status, andres.status(204).end()sends an empty response.
Each handler must send exactly one response. That is why the error cases use return res.status(...)...: the return stops the handler from carrying on and trying to respond a second time.
Middleware
Middleware functions run in the order they are added, each receiving (req, res, next). They can inspect or change the request, send a response, or call next() to pass control to the next function. express.json(), the request logger, the final 404 handler and the error handler are all middleware. Typical uses include logging, parsing bodies, checking authentication tokens, enabling CORS (with the cors package) and serving static files (with express.static).
Error handling
Middleware with four parameters (err, req, res, next) is an error handler. When a route throws an error, Express skips straight to it, so the client receives a clean 500 response instead of the server crashing. In Express 5, errors thrown inside async handlers (for example, a failed database query) are also passed to it automatically. In Express 4, you must catch them and call next(err) yourself.
Trying the API from Code
This script starts the app on a free port, sends a series of requests with the built-in fetch (available in Node.js 18 and later), prints each response, and shuts the server down. It runs every endpoint, including the error cases.
const app = require("./app.js");
const server = app.listen(0, async () => {
const base = `http://localhost:${server.address().port}/api`;
async function call(method, path, body) {
const res = await fetch(base + path, {
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
console.log(` ${res.status} ${text}`);
}
await call("GET", "/students?dept=Physics");
await call("GET", "/students/2");
await call("GET", "/students/99");
await call("POST", "/students", { name: "Dami", dept: "Maths", level: 100 });
await call("POST", "/students", { name: "", level: 700 });
await call("PUT", "/students/4", { level: 200 });
await call("DELETE", "/students/1");
await call("GET", "/students");
await call("GET", "/crash");
await call("GET", "/teachers");
server.close();
});
Output:
--> GET /api/students?dept=Physics
200 [{"id":1,"name":"Ada","dept":"Physics","level":100},{"id":3,"name":"Chidi","dept":"Physics","level":200}]
--> GET /api/students/2
200 {"id":2,"name":"Bayo","dept":"Maths","level":200}
--> GET /api/students/99
404 {"error":"student not found"}
--> POST /api/students
201 {"id":4,"name":"Dami","dept":"Maths","level":100}
--> POST /api/students
400 {"errors":["name is required","dept is required","level must be 100-500"]}
--> PUT /api/students/4
200 {"id":4,"name":"Dami","dept":"Maths","level":200}
--> DELETE /api/students/1
204
--> GET /api/students
200 [{"id":2,"name":"Bayo","dept":"Maths","level":200},{"id":3,"name":"Chidi","dept":"Physics","level":200},{"id":4,"name":"Dami","dept":"Maths","level":200}]
--> GET /api/crash
error: something broke
500 {"error":"internal server error"}
--> GET /api/teachers
404 {"error":"no route for GET /api/teachers"}
The lines starting with --> come from the logging middleware on the server, and the indented lines are the client's view of each response. Passing port 0 to listen asks the operating system for any free port, which is handy for tests.
Running the Server for Real
To run the API as a normal server, create server.js:
// server.js
const app = require("./app.js");
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`API listening on http://localhost:${PORT}`));
Then start it and send requests with curl (or a tool such as Postman, or the Thunder Client extension in VS Code):
In a terminal:
node server.js
curl http://localhost:3000/api/students
curl -X POST http://localhost:3000/api/students -H "Content-Type: application/json" -d '{"name":"Efe","dept":"Physics","level":300}'
Reading the port from process.env.PORT matters when deploying: hosting platforms such as Render and Railway tell your app which port to use through that environment variable. During development, node --watch server.js restarts the server automatically whenever you save a file.
Organising Routes with a Router
As an API grows, put each resource's routes in its own file with express.Router(), and mount it on a path:
// routes-sketch.js
const express = require("express");
const router = express.Router();
router.get("/", (req, res) => res.json([])); // handles GET /api/courses
router.get("/:code", (req, res) => res.json({ code: req.params.code }));
module.exports = router;
// In app.js:
// const courseRoutes = require("./routes/courses");
// app.use("/api/courses", courseRoutes);
Inside the router, paths are relative to where it is mounted, so "/:code" becomes /api/courses/:code. A common project layout separates routes/ (URLs), controllers/ (handler functions), models/ (database schemas) and middleware/ (authentication, validation and so on).
Good Practices for an Express REST API
- Use nouns for resources and HTTP methods for actions:
POST /api/students, not/api/createStudent. - Return accurate status codes, and JSON error messages that explain what went wrong.
- Validate every input on the server, even if the front end validates it too. Libraries such as
zodorexpress-validatorhelp. - Never trust or expose secrets: keep database passwords and API keys in environment variables (a
.envfile locally, loaded withdotenv), and never commit them to Git. - Protect routes that change data with authentication, for example by checking a JSON Web Token in middleware.
Common Mistakes with Express REST APIs
- Forgetting
app.use(express.json()), so thatreq.bodyisundefined. - Comparing
req.params.id(a string) with a number using===. Convert it first. - Sending two responses, which causes the error "Cannot set headers after they are sent". Use
returnafter responding. - Forgetting to call
next()in middleware, which leaves requests hanging forever. - Adding the 404 or error handler before the routes. Middleware runs in order, so these must come last.
Express REST API: Practice Exercises
- Add a route
GET /api/statsthat returns the number of students in each department. - Add support for
?level=200alongside?dept=in the list route. - Write middleware that rejects requests without the header
x-api-key: secret123with status 401.
Sample solutions:
const express = require("express");
const app = express();
const students = [
{ id: 1, name: "Ada", dept: "Physics", level: 100 },
{ id: 2, name: "Bayo", dept: "Maths", level: 200 },
{ id: 3, name: "Chidi", dept: "Physics", level: 200 },
];
function requireKey(req, res, next) {
if (req.get("x-api-key") !== "secret123") return res.status(401).json({ error: "invalid API key" });
next();
}
app.get("/api/stats", (req, res) => {
const counts = {};
for (const s of students) counts[s.dept] = (counts[s.dept] ?? 0) + 1;
res.json(counts);
});
app.get("/api/students", requireKey, (req, res) => {
let result = students;
if (req.query.dept) result = result.filter(s => s.dept === req.query.dept);
if (req.query.level) result = result.filter(s => s.level === Number(req.query.level));
res.json(result.map(s => s.name));
});
const server = app.listen(0, async () => {
const base = `http://localhost:${server.address().port}`;
const show = async (path, headers = {}) => {
const r = await fetch(base + path, { headers });
console.log(r.status, await r.text());
};
await show("/api/stats");
await show("/api/students?level=200");
await show("/api/students?dept=Physics&level=200", { "x-api-key": "secret123" });
server.close();
});
Output:
200 {"Physics":2,"Maths":1}
401 {"error":"invalid API key"}
200 ["Chidi"]
Passing requireKey as an extra argument applies the middleware to that one route only. To protect every route, add it with app.use(requireKey) instead.
Conclusion
Express builds REST APIs in Node.js with very little code. Create an app, add express.json() to parse bodies, and define routes with app.get, post, put and delete, reading req.params, req.query and req.body, and replying with res.status().json() and the right status code. Middleware handles cross-cutting jobs such as logging and authentication, a four-parameter handler catches errors, and a final catch-all returns 404s. Organise larger APIs with express.Router(), validate all input, keep secrets in environment variables, and read the port from process.env.PORT when deploying.