REST API Design with Express.js: A Practical Guide to Resources, Routes, and HTTP Methods

REST API Design with Express.js: A Practical Guide to Resources, Routes, and HTTP Methods
TL;DR: REST is a set of conventions for structuring HTTP APIs around resources. This guide walks through what those conventions are, why they exist, and how to implement them correctly in Express.js using a users resource as the working example.
This post assumes familiarity with JavaScript and basic Node.js. You don't need prior API experience, but knowing what a function and a callback are will help.
What Is a REST API?
An API (Application Programming Interface) is a contract between a client and a server. The client sends a request — "give me this data" or "save this record" — and the server responds with a result.
REST (Representational State Transfer) is a set of architectural rules that makes that communication predictable. When an API follows REST principles, any developer can look at a route like GET /users/42 and immediately understand: this fetches user with ID 42. No documentation required.
The core idea: expose resources, not actions.
- ❌ Non-REST:
POST /getUser,POST /deleteUserById - ✅ REST:
GET /users/:id,DELETE /users/:id
Mental Model: Resources
A resource is any entity your API manages — a user, an order, a product, a comment. Think of it as a noun.
Each resource gets its own URL (called an endpoint). The HTTP method on that URL defines the action:
| HTTP Method | Action | SQL Equivalent |
| GET | Read | SELECT |
| POST | Create | INSERT |
| PUT | Full Update | UPDATE |
| PATCH | Partial Update | UPDATE |
| DELETE | Remove | DELETE |
For a users resource, the standard REST routes look like this:
| Method | Route | Action |
| GET | /users | Get all users |
| GET | /users/:id | Get one user by ID |
| POST | /users | Create a new user |
| PUT | /users/:id | Replace a user completely |
| DELETE | /users/:id | Delete a user |
Setting Up the Project
Prerequisites: Node.js 18+ installed.
mkdir rest-api-demo && cd rest-api-demo
npm init -y
npm install express
Create index.js as the entry point.
Step 1: Bootstrap the Express Server
This is the minimal server setup before any routes are added.
// index.js
const express = require('express');
const app = express();
const PORT = 3000;
// Parse incoming JSON request bodies
app.use(express.json());
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
express.json() is middleware that parses JSON from request bodies. Without it, req.body is always undefined on POST/PUT requests.
Step 2: Define an In-Memory Data Store
We'll use an array instead of a database to keep the focus on API design, not ORM configuration.
// In-memory store (place this above your routes in index.js)
let users = [
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com' },
{ id: 2, name: 'Bob Smith', email: 'bob@example.com' },
];
let nextId = 3; // Auto-increment counter
Step 3: GET /users — Fetch All Users
// GET /users → return all users
app.get('/users', (req, res) => {
res.status(200).json(users);
});
Test it:
curl http://localhost:3000/users
Expected output:
[
{ "id": 1, "name": "Alice Johnson", "email": "alice@example.com" },
{ "id": 2, "name": "Bob Smith", "email": "bob@example.com" }
]
200 OK means the request succeeded and data is returned. Always be explicit about status codes — never rely on Express defaults alone.
Step 4: GET /users/:id — Fetch One User
:id is a route parameter. Express exposes it via req.params.id as a string — you need to convert it to a number for comparison.
// GET /users/:id → return a single user
app.get('/users/:id', (req, res) => {
const userId = parseInt(req.params.id, 10);
const user = users.find((u) => u.id === userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.status(200).json(user);
});
Test it:
curl http://localhost:3000/users/1
# { "id": 1, "name": "Alice Johnson", "email": "alice@example.com" }
curl http://localhost:3000/users/99
# { "error": "User not found" } → HTTP 404
Returning 404 Not Found when a resource doesn't exist is essential. It tells the client "this thing doesn't exist" versus 400 Bad Request which means "your request is malformed."
Step 5: POST /users — Create a New User
POST carries data in the request body. The server creates the resource and returns it with a 201 Created status.
// POST /users → create a new user
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 newUser = { id: nextId++, name, email };
users.push(newUser);
res.status(201).json(newUser);
});
Test it:
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "Carol White", "email": "carol@example.com"}'
Expected output:
{ "id": 3, "name": "Carol White", "email": "carol@example.com" }
Status 201 explicitly signals that a resource was created — different from 200. Some clients and logging tools use this distinction to trigger different behaviors.
Step 6: PUT /users/:id — Replace a User
PUT replaces the entire resource. If you send only name, the email field should be wiped. This is what differentiates PUT from PATCH.
// PUT /users/:id → fully replace a user
app.put('/users/:id', (req, res) => {
const userId = parseInt(req.params.id, 10);
const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex === -1) {
return res.status(404).json({ error: 'User not found' });
}
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'name and email are required for full update' });
}
users[userIndex] = { id: userId, name, email };
res.status(200).json(users[userIndex]);
});
Test it:
curl -X PUT http://localhost:3000/users/1 \
-H "Content-Type: application/json" \
-d '{"name": "Alice Cooper", "email": "alice.cooper@example.com"}'
Step 7: DELETE /users/:id — Remove a User
// DELETE /users/:id → remove a user
app.delete('/users/:id', (req, res) => {
const userId = parseInt(req.params.id, 10);
const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex === -1) {
return res.status(404).json({ error: 'User not found' });
}
users.splice(userIndex, 1);
res.status(204).send(); // 204 = No Content
});
Test it:
curl -X DELETE http://localhost:3000/users/2
# HTTP 204 — no body returned
204 No Content is the correct response for a successful DELETE. There's nothing meaningful to return — the resource is gone.
Complete Working File
Here's the full index.js with everything combined:
// index.js — Full REST API for users resource
const express = require('express');
const app = express();
const PORT = 3000;
app.use(express.json());
let users = [
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com' },
{ id: 2, name: 'Bob Smith', email: 'bob@example.com' },
];
let nextId = 3;
// GET all users
app.get('/users', (req, res) => {
res.status(200).json(users);
});
// GET one user
app.get('/users/:id', (req, res) => {
const user = users.find((u) => u.id === parseInt(req.params.id, 10));
if (!user) return res.status(404).json({ error: 'User not found' });
res.status(200).json(user);
});
// POST create user
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 newUser = { id: nextId++, name, email };
users.push(newUser);
res.status(201).json(newUser);
});
// PUT update user
app.put('/users/:id', (req, res) => {
const idx = users.findIndex((u) => u.id === parseInt(req.params.id, 10));
if (idx === -1) return res.status(404).json({ error: 'User not found' });
const { name, email } = req.body;
if (!name || !email) return res.status(400).json({ error: 'name and email are required' });
users[idx] = { id: users[idx].id, name, email };
res.status(200).json(users[idx]);
});
// DELETE user
app.delete('/users/:id', (req, res) => {
const idx = users.findIndex((u) => u.id === parseInt(req.params.id, 10));
if (idx === -1) return res.status(404).json({ error: 'User not found' });
users.splice(idx, 1);
res.status(204).send();
});
app.listen(PORT, () => console.log(`Server running at http://localhost:${PORT}`));
Run it:
node index.js
# Server running at http://localhost:3000
Status Codes Reference
| Code | Name | When to Use |
| 200 | OK | Successful GET or PUT |
| 201 | Created | Successful POST |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Missing or invalid input |
| 404 | Not Found | Resource doesn't exist |
| 500 | Internal Server Error | Unhandled server-side failure |
Never return 200 for errors. It breaks clients that check status codes before reading the body.
REST Request-Response Lifecycle
Client Server (Express)
| |
|--- GET /users/1 ----------------->|
| Find user by ID
| user exists? yes
|<-- 200 OK { id:1, name:... } -----|
|
|--- DELETE /users/99 ------------->|
| Find user by ID
| user exists? no
|<-- 404 { error: 'Not found' } ----|
Trade-offs
What this approach gets right:
- Predictable, self-documenting URLs
- HTTP method semantics match intent
- Stateless — each request carries all context the server needs
Where REST has friction:
- Over-fetching:
GET /usersreturns all fields even if you need onlyname - Under-fetching: you might need
GET /users/1+GET /users/1/postsfor related data — GraphQL solves this - Versioning:
/v1/usersvs/v2/usersbecomes messy at scale - Real-time: REST is request-response only — WebSockets or SSE needed for live updates
When NOT to use plain REST:
- You need real-time bidirectional communication → use WebSockets
- Clients need flexible queries across related data → consider GraphQL
- Internal service-to-service communication at high volume → consider gRPC
Conclusion
REST works because it reuses HTTP — a protocol every client already understands. By naming routes as nouns (/users, not /getUsers), mapping intent to HTTP methods, and returning meaningful status codes, you build APIs that require no guesswork.
The users example here covers 90% of what most APIs do. Once this pattern is solid, apply it to any resource: orders, products, comments. The shape stays the same.
Next step: Add input validation with a library like zod or joi, then connect a real database with Prisma or pg. The route handlers stay nearly identical.



