# 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.

```bash
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.

```javascript
// 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.

```javascript
// 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

```javascript
// GET /users → return all users
app.get('/users', (req, res) => {
  res.status(200).json(users);
});
```

**Test it:**
```bash
curl http://localhost:3000/users
```

**Expected output:**
```json
[
  { "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.

```javascript
// 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:**
```bash
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.

```javascript
// 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:**
```bash
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Carol White", "email": "carol@example.com"}'
```

**Expected output:**
```json
{ "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.

```javascript
// 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:**
```bash
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

```javascript
// 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:**
```bash
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:

```javascript
// 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:
```bash
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 /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

- [Express.js Official Routing Docs](https://expressjs.com/en/guide/routing.html)
- [MDN: HTTP Methods](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods)
- [MDN: HTTP Status Codes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status)
- [REST API Design Best Practices — Microsoft](https://learn.microsoft.com/en-us/azure/architecture/best-practices/api-design)
- [Zod — TypeScript-first schema validation](https://zod.dev)
