Building a REST API with Node.js and Express
Step-by-step tutorial to build a production-ready REST API. Learn routing, middleware, error handling, and best practices with Node.js and Express.
REST APIs are the backbone of modern web applications. Every time a mobile app loads a feed, a React frontend saves a form, or two backend services exchange data, there is usually a REST API in the middle. In this tutorial you'll build one from scratch with Node.js and Express: a todo API with full CRUD (create, read, update, delete), reusable middleware, and centralized error handling, with the reasoning behind every piece.
You'll need Node.js 20 or newer. Everything else we install as we go.
What makes an API RESTful?
REST is a set of conventions, not a technology. Before writing any code, it helps to know the rules of the game:
- Everything is a resource. Todos, users, orders. Each resource type gets a URL like
/api/todos, and each individual resource gets one like/api/todos/42. - HTTP methods describe the action. You never put verbs in the URL (
/getTodos,/deleteTodo). The method already says what you want to do. - The server is stateless. Every request carries everything needed to handle it. That's what lets you scale later by simply running more copies of the server.
- Status codes tell the truth. A missing todo returns
404 Not Found, not a200 OKwith an error message buried in the body. Clients and monitoring tools rely on this.
Here's the exact mapping we'll implement:
| Method | Route | Action | Success status |
|---|---|---|---|
| GET | /api/todos | List all todos | 200 |
| POST | /api/todos | Create a todo | 201 |
| GET | /api/todos/:id | Fetch one todo | 200 |
| PUT | /api/todos/:id | Update a todo | 200 |
| DELETE | /api/todos/:id | Delete a todo | 200 |
If you internalize that table, you already understand the shape of most REST APIs you will ever work with.
Project setup
Create the project and install the dependencies:
mkdir todo-api && cd todo-api
npm init -y
npm install express dotenv cors uuid
# This tutorial uses ES module syntax (import/export), so enable it:
npm pkg set type=module
A quick word on each choice. express is the web framework: routing, request parsing, and middleware without hand-rolling them on top of Node's raw http module. cors lets browsers on other origins (like a React dev server on localhost:5173) call your API. uuid generates collision-resistant IDs. dotenv loads configuration from a .env file, keeping secrets out of your code. And npm pkg set type=module switches the project to ES modules so we can write modern import/export instead of require.
One version note: npm install express today gives you Express 5, which automatically forwards errors thrown in async route handlers to your error middleware. On Express 4 you had to catch and forward those yourself, and forgetting was a classic source of hung requests.
Your first server
Start with the smallest thing that runs:
// server.js
import express from 'express';
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Two lines deserve attention. express.json() is built-in middleware that parses incoming JSON bodies and puts the result on req.body. Skip it and req.body is undefined, which breaks every POST handler in confusing ways. And process.env.PORT || 3000 reads the port from the environment first, because hosting platforms assign you a port at runtime; hardcoding one works locally and mysteriously fails in production.
Run node server.js and you have a server that accepts requests and does nothing useful. Let's fix that.
Designing the routes
An express.Router lets you define all todo routes in their own file and mount them under a prefix later. This separation matters more as an API grows: a real project might have ten resource files, each self-contained and testable.
// routes/todos.js
import express from 'express';
import { v4 as uuidv4 } from 'uuid';
const router = express.Router();
let todos = [];
// GET all todos
router.get('/', (req, res) => {
res.json(todos);
});
// POST create a todo
router.post('/', (req, res) => {
const { title, description } = req.body;
if (!title || typeof title !== 'string') {
return res.status(400).json({ error: 'Title is required' });
}
const todo = {
id: uuidv4(),
title: title.trim(),
description: description || '',
completed: false,
createdAt: new Date().toISOString(),
};
todos.push(todo);
res.status(201).json(todo);
});
// GET a single todo
router.get('/:id', (req, res) => {
const todo = todos.find(t => t.id === req.params.id);
if (!todo) {
return res.status(404).json({ error: 'Todo not found' });
}
res.json(todo);
});
// PUT update a todo (only the fields we allow)
router.put('/:id', (req, res) => {
const todo = todos.find(t => t.id === req.params.id);
if (!todo) {
return res.status(404).json({ error: 'Todo not found' });
}
const { title, description, completed } = req.body;
if (title !== undefined) todo.title = title;
if (description !== undefined) todo.description = description;
if (completed !== undefined) todo.completed = Boolean(completed);
res.json(todo);
});
// DELETE a todo
router.delete('/:id', (req, res) => {
const index = todos.findIndex(t => t.id === req.params.id);
if (index === -1) {
return res.status(404).json({ error: 'Todo not found' });
}
const [deleted] = todos.splice(index, 1);
res.json(deleted);
});
export default router;
Every decision in that file is deliberate:
- Validation happens at the edge. The POST handler rejects a missing title with
400 Bad Requestbefore touching any data. Garbage that gets past your route handlers ends up in your database, and cleaning it out later is far more painful than rejecting it now. returnbefore every error response. Withoutreturn, execution continues pastres.status(404).json(...)and hits the nextrescall, producing the classic "Cannot set headers after they are sent" crash.- The PUT handler whitelists fields. It copies only
title,description, andcompletedfrom the body. The lazy version,Object.assign(todo, req.body), lets any client overwriteidorcreatedAt. That pattern is called mass assignment, and it's a genuine vulnerability class, not just untidiness. - POST returns
201 Createdalong with the created object, so clients immediately get the generatedidwithout a second request.
The todos array is our stand-in database. It resets on every restart and can't be shared across multiple server instances, which makes it fine for learning and disqualifying for production. The good news: because all data access lives in this one file, swapping in PostgreSQL later changes almost nothing else.
Middleware: the assembly line
Middleware is the idea that makes Express feel like Express. Every request flows through a chain of functions, and each one can inspect the request, modify it, respond early, or pass control onward by calling next(). Authentication, logging, rate limiting, and body parsing are all just middleware.
// middleware/auth.js
export const authenticate = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
// Verify token (simplified; real apps verify a signed JWT here)
if (token !== 'valid-token') {
return res.status(403).json({ error: 'Invalid token' });
}
next();
};
// middleware/logger.js
export const logger = (req, res, next) => {
console.log(`${req.method} ${req.path}`);
next();
};
Notice the two failure codes in authenticate: 401 means "you haven't proven who you are," while 403 means "we know who you are, and you're not allowed." Clients handle them differently (a 401 usually triggers a login redirect). A real app would verify a signed JWT with a library like jsonwebtoken instead of comparing strings, but the shape of the middleware stays identical.
The one rule that trips everyone up: middleware runs in registration order. If you register your logger after your routes, it never sees the requests those routes handled. Order is the first thing to check when middleware "isn't working."
Centralized error handling
Rather than repeating try/catch blocks with response logic in every handler, Express lets you define one error handler for the whole app:
// middleware/errorHandler.js
export const errorHandler = (err, req, res, next) => {
console.error(err);
if (err.name === 'ValidationError') {
return res.status(400).json({ error: err.message });
}
res.status(err.status || 500).json({
error: err.message || 'Internal server error',
});
};
The four-argument signature is not decorative. Express identifies error-handling middleware by arity, so (err, req, res, next) must all be present even if you don't use next. Any error thrown in a route (or passed to next(err)) skips the remaining middleware and lands here: one place to log, one place to shape responses, one place to keep internal details from leaking to clients.
Wiring it all together
// server.js
import express from 'express';
import cors from 'cors';
import todoRoutes from './routes/todos.js';
import { logger } from './middleware/logger.js';
import { errorHandler } from './middleware/errorHandler.js';
const app = express();
// Middleware (order matters)
app.use(express.json());
app.use(cors());
app.use(logger);
// Routes
app.use('/api/todos', todoRoutes);
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
// 404 for anything no route matched
app.use((req, res) => {
res.status(404).json({ error: 'Route not found' });
});
// Error handler goes last
app.use(errorHandler);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Read the order top to bottom: parse bodies, allow cross-origin calls, log, then try the routes. If nothing matched, the catch-all returns a JSON 404 instead of Express's default HTML page. The error handler is registered dead last so it can catch failures from everything above it. The humble /health endpoint is what load balancers and uptime monitors ping to know your service is alive.
Testing it with curl
Exercise every endpoint from a second terminal:
# Create a todo
curl -X POST http://localhost:3000/api/todos \
-H "Content-Type: application/json" \
-d '{"title":"Learn Express"}'
# Get all todos
curl http://localhost:3000/api/todos
# Get a single todo (paste an id from the create response)
curl http://localhost:3000/api/todos/{id}
# Update a todo
curl -X PUT http://localhost:3000/api/todos/{id} \
-H "Content-Type: application/json" \
-d '{"completed":true}'
# Delete a todo
curl -X DELETE http://localhost:3000/api/todos/{id}
Don't just check the bodies. Check the status codes (curl -i shows them): 201 on create, 400 when you omit the title, 404 for a made-up id.
Common mistakes to avoid
- Returning
200for everything. Some APIs send{"success": false}with a200status. Monitoring tools, caches, and client libraries all key off status codes, so this breaks the whole ecosystem around your API. - Verbs in URLs.
/api/createTodofights the conventions every client library expects. Nouns in the URL, verbs in the method. - Trusting
req.bodywholesale. Assigning the raw body onto your objects invites mass assignment. Always pick out the fields you allow. - Forgetting
returnbefore early responses. Double-send errors are one of the most common Express crashes for beginners. - Keeping data in memory in production. It vanishes on restart and desynchronizes the moment you run two instances behind a load balancer.
- Skipping rate limiting and HTTPS. Fine on localhost, negligent on the public internet. Add
express-rate-limitand terminate TLS before real users arrive.
Where to go next
This API has the right skeleton, so upgrades slot in cleanly: replace the array with PostgreSQL, replace the fake token check with signed JWTs, add schema validation with a library like zod, and write integration tests against the routes. Each is a focused change because routing, middleware, and error handling are already separated.
Frequently asked questions
Do I need Express, or can I use plain Node?
Node's built-in http module can serve an API, and native fetch covers the client side. But you'd be rebuilding routing, body parsing, and middleware yourself. Express removes exactly that boilerplate while keeping you close to the platform.
What's the difference between PUT and PATCH?
By convention, PUT replaces the whole resource with what you send, while PATCH applies a partial change. In practice many APIs (including this tutorial's) accept partial updates on PUT. Whichever you choose, be consistent and document it.
How do I connect this API to a database?
Keep the routes identical and swap the array operations for queries, usually through an ORM like Prisma or a query builder like Knex. The handler shape stays the same: validate input, perform the operation, return the right status code.
Should I version my API?
Yes, from day one. Mounting routes under /api/v1/todos costs nothing now and saves you enormous pain later, because it lets you introduce breaking changes under /api/v2 while old clients keep working.
Keep building
The concepts you used here, resources, HTTP methods, status codes, middleware, and centralized error handling, transfer directly to every backend stack you'll ever touch. If your JavaScript itself still feels shaky, shore it up with the interactive JavaScript Fundamentals course on Kodion, and when you want to see the same REST ideas from another angle, the FastAPI REST APIs course builds a production-style API in Python with the same principles you just learned.
Kodion Team
Kodion Editorial
Written by the team that builds Kodion's courses: working engineers who test every example in the interactive editor before it ships. How we create and review content
Continue Learning
📚 Related Articles
What Is an API? A Plain-English Guide for New Developers
APIs power every app you use, and every developer job expects you to know them. What an API really is, how REST works, and how to call one in ten lines.
Async JavaScript Explained: Callbacks, Promises, and Async/Await
Asynchronous code is where JavaScript beginners get stuck. A clear path from callbacks to promises to async/await, with the mental model that makes it click.