Skip to main content

Command Palette

Search for a command to run...

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

Updated
8 min read
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 MethodActionSQL Equivalent
GETReadSELECT
POSTCreateINSERT
PUTFull UpdateUPDATE
PATCHPartial UpdateUPDATE
DELETERemoveDELETE

For a users resource, the standard REST routes look like this:

MethodRouteAction
GET/usersGet all users
GET/users/:idGet one user by ID
POST/usersCreate a new user
PUT/users/:idReplace a user completely
DELETE/users/:idDelete 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

CodeNameWhen to Use
200OKSuccessful GET or PUT
201CreatedSuccessful POST
204No ContentSuccessful DELETE
400Bad RequestMissing or invalid input
404Not FoundResource doesn't exist
500Internal Server ErrorUnhandled 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 /users returns all fields even if you need only name
  • Under-fetching: you might need GET /users/1 + GET /users/1/posts for related data — GraphQL solves this
  • Versioning: /v1/users vs /v2/users becomes 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.


Further Reading

More from this blog