Build Your First REST API with Express
A hands-on tutorial — install Express, define GET/POST/PUT/DELETE routes, parse JSON bodies, use route params, and return proper status codes for an in-memory todos API.
What you'll learn
- ✓How to install Express and start a server
- ✓How to define routes for GET, POST, PUT, and DELETE
- ✓How to read query strings, route params, and JSON bodies
- ✓Which HTTP status codes to return for which outcomes
- ✓How to build a tiny CRUD API for /todos backed by an in-memory list
Prerequisites
- •Node installed and a package.json — see npm and package.json
- •Comfort with async/await — see JavaScript async/await
- •A rough idea of REST — see What Is REST?
Express is the most-used web framework in the Node ecosystem. It is small, unopinionated, and has been the default choice for a decade. In this post, you will build a working CRUD API for a list of todos, with proper status codes, error handling, and the routes a real REST client expects.
Set up the project
Start fresh:
mkdir todo-api && cd todo-api
npm init -y
npm install express
Open package.json and add "type": "module" so we can use modern import syntax — see CommonJS vs ES Modules for context. Also add a dev script:
{
"type": "module",
"scripts": {
"dev": "node --watch index.js"
},
"dependencies": {
"express": "^4.19.2"
}
}
node --watch restarts the server whenever you save a file — no separate tool needed.
A minimal server
Create index.js:
import express from 'express';
const app = express();
app.get('/', (req, res) => {
res.send('Hello from Express!');
});
app.listen(3000, () => {
console.log('Listening on http://localhost:3000');
});
Run it:
npm run dev
# output: Listening on http://localhost:3000
Visit http://localhost:3000 in your browser. You should see Hello from Express!. That is a complete HTTP server in ten lines.
How routing works
Each route is a method call: app.get, app.post, app.put, app.delete. The first argument is the path, the second is a handler that receives req and res.
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
res.json serialises the argument to JSON and sets Content-Type: application/json. It is the response method you will use most.
In-memory data
For learning, a plain array beats a database. We’ll keep todos in memory and reset on restart:
let todos = [
{ id: 1, text: 'Learn Express', done: false },
{ id: 2, text: 'Build first API', done: false },
];
let nextId = 3;
List all todos: GET /todos
app.get('/todos', (req, res) => {
res.json(todos);
});
Test it from another terminal with curl:
curl http://localhost:3000/todos
# output:
# [{"id":1,"text":"Learn Express","done":false},
# {"id":2,"text":"Build first API","done":false}]
Filter with query strings
Query parameters arrive on req.query:
app.get('/todos', (req, res) => {
const { done } = req.query;
if (done === 'true') {
return res.json(todos.filter(t => t.done));
}
if (done === 'false') {
return res.json(todos.filter(t => !t.done));
}
res.json(todos);
});
curl 'http://localhost:3000/todos?done=true'
# output: []
req.query values are always strings (or arrays of strings), even when they look like booleans or numbers. Coerce them explicitly.
Get one todo: GET /todos/:id
Route parameters use :name syntax and land on req.params:
app.get('/todos/:id', (req, res) => {
const id = Number(req.params.id);
const todo = todos.find(t => t.id === id);
if (!todo) {
return res.status(404).json({ error: 'Todo not found' });
}
res.json(todo);
});
Two things here that matter:
req.params.idis a string. Convert before comparing.res.status(404)sets the status code, then.json(...)sends the body. Chain them.
curl http://localhost:3000/todos/1
# output: {"id":1,"text":"Learn Express","done":false}
curl -i http://localhost:3000/todos/999
# output:
# HTTP/1.1 404 Not Found
# {"error":"Todo not found"}
Parsing JSON bodies
Before handling POSTs, tell Express to parse incoming JSON:
app.use(express.json());
This middleware runs on every request and populates req.body when the content type is application/json. Without it, req.body is undefined.
Create a todo: POST /todos
app.post('/todos', (req, res) => {
const { text } = req.body ?? {};
if (typeof text !== 'string' || text.trim() === '') {
return res.status(400).json({ error: 'text is required' });
}
const todo = { id: nextId++, text: text.trim(), done: false };
todos.push(todo);
res.status(201).json(todo);
});
Notes:
400 Bad Requestfor malformed input. Always validate.201 Createdfor a successful creation, with the new resource in the body.- The server assigns the
id. Never trust anidfrom the client for creation.
Test it:
curl -X POST http://localhost:3000/todos \
-H 'Content-Type: application/json' \
-d '{"text":"Write a blog post"}'
# output:
# {"id":3,"text":"Write a blog post","done":false}
Try it yourself. Add a POST request that creates a todo, then a GET that fetches it by its returned id. Confirm a POST with a missing text field returns 400 and that fetching id 9999 returns 404. Use curl -i to see status codes alongside the bodies.
Update a todo: PUT /todos/:id
PUT replaces the whole resource. PATCH would modify selected fields — we will use PUT here for simplicity but accept partial updates:
app.put('/todos/:id', (req, res) => {
const id = Number(req.params.id);
const todo = todos.find(t => t.id === id);
if (!todo) {
return res.status(404).json({ error: 'Todo not found' });
}
const { text, done } = req.body ?? {};
if (text !== undefined) {
if (typeof text !== 'string' || text.trim() === '') {
return res.status(400).json({ error: 'text must be a non-empty string' });
}
todo.text = text.trim();
}
if (done !== undefined) {
if (typeof done !== 'boolean') {
return res.status(400).json({ error: 'done must be a boolean' });
}
todo.done = done;
}
res.json(todo);
});
curl -X PUT http://localhost:3000/todos/1 \
-H 'Content-Type: application/json' \
-d '{"done":true}'
# output: {"id":1,"text":"Learn Express","done":true}
Delete a todo: DELETE /todos/:id
app.delete('/todos/:id', (req, res) => {
const id = Number(req.params.id);
const index = todos.findIndex(t => t.id === id);
if (index === -1) {
return res.status(404).json({ error: 'Todo not found' });
}
todos.splice(index, 1);
res.status(204).end();
});
204 No Content is the conventional response for a successful DELETE with no body. res.end() sends the response without a payload.
curl -i -X DELETE http://localhost:3000/todos/2
# output:
# HTTP/1.1 204 No Content
Status codes worth memorising
| Code | Meaning | When |
|---|---|---|
| 200 | OK | Successful GET/PUT/PATCH |
| 201 | Created | Successful POST that creates a resource |
| 204 | No Content | Successful DELETE, or any success without a body |
| 400 | Bad Request | Invalid or missing input |
| 401 | Unauthorized | Missing or bad credentials |
| 403 | Forbidden | Authenticated but not allowed |
| 404 | Not Found | Resource does not exist |
| 409 | Conflict | The request conflicts with current state |
| 500 | Internal Server Error | Your bug — return only when you have nothing better |
You will rarely need more than these. See What Is REST? for the longer list.
Centralised error handling
Wrap risky logic and forward errors to Express:
app.get('/crash', (req, res, next) => {
try {
throw new Error('something blew up');
} catch (err) {
next(err);
}
});
// Error-handling middleware has four args
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: 'Internal Server Error' });
});
The four-argument signature is how Express recognises an error handler. Put it at the bottom of your app.
The full file
import express from 'express';
const app = express();
app.use(express.json());
let todos = [
{ id: 1, text: 'Learn Express', done: false },
{ id: 2, text: 'Build first API', done: false },
];
let nextId = 3;
app.get('/todos', (req, res) => {
const { done } = req.query;
if (done === 'true') return res.json(todos.filter(t => t.done));
if (done === 'false') return res.json(todos.filter(t => !t.done));
res.json(todos);
});
app.get('/todos/:id', (req, res) => {
const id = Number(req.params.id);
const todo = todos.find(t => t.id === id);
if (!todo) return res.status(404).json({ error: 'Todo not found' });
res.json(todo);
});
app.post('/todos', (req, res) => {
const { text } = req.body ?? {};
if (typeof text !== 'string' || text.trim() === '') {
return res.status(400).json({ error: 'text is required' });
}
const todo = { id: nextId++, text: text.trim(), done: false };
todos.push(todo);
res.status(201).json(todo);
});
app.put('/todos/:id', (req, res) => {
const id = Number(req.params.id);
const todo = todos.find(t => t.id === id);
if (!todo) return res.status(404).json({ error: 'Todo not found' });
const { text, done } = req.body ?? {};
if (text !== undefined) {
if (typeof text !== 'string' || text.trim() === '') {
return res.status(400).json({ error: 'text must be a non-empty string' });
}
todo.text = text.trim();
}
if (done !== undefined) {
if (typeof done !== 'boolean') {
return res.status(400).json({ error: 'done must be a boolean' });
}
todo.done = done;
}
res.json(todo);
});
app.delete('/todos/:id', (req, res) => {
const id = Number(req.params.id);
const index = todos.findIndex(t => t.id === id);
if (index === -1) return res.status(404).json({ error: 'Todo not found' });
todos.splice(index, 1);
res.status(204).end();
});
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: 'Internal Server Error' });
});
app.listen(3000, () => console.log('Listening on http://localhost:3000'));
That is a real, working REST API in roughly 60 lines.
Try it yourself. Add a new endpoint POST /todos/:id/toggle that flips a todo’s done field and returns the updated resource. It should return 404 if the id is unknown. Then add a GET /todos/search?q=foo route that returns todos whose text contains the query string (case-insensitive).
Where to go from here
This server forgets everything on restart because the data lives in memory. The next layer of real APIs is persistence — a database like Postgres or SQLite, accessed through a driver or ORM. The HTTP code you wrote barely changes; the array just becomes a table.
Other natural next steps:
- Validation with a schema library like
zodto replace the hand-rolled checks - Authentication with JWTs or session cookies
- Testing with
vitestand a request library likesupertest - Logging with a structured logger like
pino
Recap
You now know:
express()builds an app;app.get/post/put/deleteregister routesexpress.json()middleware parses JSON request bodies intoreq.body- Route params land on
req.params, query strings onreq.query res.status(code).json(body)sends a typed response with a status code- A complete CRUD API needs GET list, GET one, POST create, PUT update, DELETE
- 201, 204, 400, 404 are the status codes you will reach for most often
Next steps
You have built a REST API; you should also understand the architectural style behind the letters in REST.
Next: What Is REST? The Architectural Style Explained
Questions or feedback? Email codeloomdevv@gmail.com.
Related articles
- Node.js Event Emitters in Node.js
Understand the EventEmitter pattern in Node.js. Covers custom emitters, error handling, async events, memory leaks, and real-world patterns.
- Node.js Node.js Native Test Runner
Learn to write tests with Node.js built-in test runner. Covers test organization, assertions, mocking, subtests, code coverage, and watch mode.
- Node.js Node.js Streams: A Practical Guide
Master Node.js streams for efficient data processing. Covers readable, writable, transform, and duplex streams with real-world examples.
- Node.js Worker Threads in Node.js
Learn how to use Worker Threads for CPU-intensive tasks in Node.js. Covers thread creation, message passing, SharedArrayBuffer, and thread pools.