Skip to main content

πŸ”Œ Building RESTful APIs with Express

An API is the contract between your server and everything that talks to it β€” web apps, mobile apps, and other services. In this lesson you'll learn the REST style that has become the web's default and turn its principles into a real Express API: clean resource URLs, the right HTTP method for each action, honest status codes, and a tidy routes-plus-controller layout you can grow.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the core constraints of REST and why statelessness matters
  • Map CRUD operations to the correct HTTP methods and distinguish PUT from PATCH
  • Design resource URIs using nouns, plurals, and nesting β€” and know when to reach for query parameters
  • Return accurate HTTP status codes for success and error cases
  • Build a working Express API with routers and controllers using async/await

Estimated Time: 45–60 minutes  β€’  Difficulty: Intermediate

Hands-on: Build a small /api/tasks resource with full CRUD and correct status codes.

In This Lesson

What REST Really Means

REST (Representational State Transfer) is an architectural style β€” a set of constraints for designing networked applications β€” not a library or a protocol. When an API follows those constraints, we call it "RESTful." The big idea: model your application as a collection of resources (users, products, orders), give each one a stable address (a URL), and manipulate them with the standard verbs the web already provides.

πŸ’‘ A useful analogy: A RESTful API is a well-run library. Each book is a resource, its shelf location is the URI, and the actions are always the same β€” browse the catalog (GET), donate a new book (POST), replace a worn copy (PUT), correct a typo in the record (PATCH), or withdraw a title (DELETE). Because the rules never change from shelf to shelf, anyone can use the library without a manual.

REST rests on a handful of constraints. You don't have to memorize the academic list, but two of them shape almost every decision you'll make:

πŸ“– The constraints that matter most day-to-day

Client–server: the frontend and backend evolve independently, connected only by the API contract.

Stateless: every request carries everything the server needs to fulfill it. The server keeps no memory of previous requests from that client β€” which is exactly what lets you run ten copies of your server behind a load balancer.

Uniform interface: resources are addressed by URI and manipulated through a small, consistent set of methods.

Cacheable & layered: responses can declare themselves cacheable, and the client can't tell whether it's talking to your server directly or through a proxy/CDN.

⚠️ Stateless does not mean "no data"

Statelessness is about connection state, not stored data. Your database still remembers everything. What the server must not do is rely on in-memory session data tied to a specific request sequence. Authentication, for example, travels on every request (a token in a header) rather than living in server memory.

A RESTful request cycle A client sends an HTTP method plus a resource URI to the Express API, which identifies the resource, processes the request, and returns a representation with a status code. Client browser / app Express API 1. Identify resource 2. Process request 3. Build response Data store database GET /api/tasks/7 200 + JSON
Figure 1 β€” Each request names a resource (the URI) and an action (the method); the API returns a representation plus a status code. Nothing about the client is remembered between requests.

Real-world example: GitHub, Stripe, and Twilio all expose RESTful APIs. GitHub addresses a repository's issues at /repos/:owner/:repo/issues β€” a noun-based URL you operate on with GET, POST, PATCH, and DELETE. Once you learn the pattern for one resource, every other resource in their API feels familiar.

HTTP Methods & CRUD

The four database operations β€” Create, Read, Update, Delete β€” map cleanly onto HTTP methods. This mapping is the heart of REST:

MethodCRUDMeaningExampleIdempotent?
GETReadFetch a resource without changing itGET /api/tasksYes
POSTCreateCreate a new resource in a collectionPOST /api/tasksNo
PUTUpdateReplace a resource entirelyPUT /api/tasks/7Yes
PATCHUpdateModify part of a resourcePATCH /api/tasks/7No*
DELETEDeleteRemove a resourceDELETE /api/tasks/7Yes

πŸ“– What "idempotent" means

An operation is idempotent if calling it once has the same effect as calling it ten times. DELETE /api/tasks/7 is idempotent β€” after the first call the task is gone, and repeat calls just confirm it's gone. POST /api/tasks is not: each call creates another task. This matters because clients and proxies may safely retry idempotent requests after a network hiccup.

Here's the whole verb set wired up in Express. Notice how each handler does one thing and returns one clear status:

// GET β€” read a collection
app.get('/api/tasks', (req, res) => {
  res.json(tasks);
});

// GET β€” read one resource
app.get('/api/tasks/:id', (req, res) => {
  const task = findTaskById(req.params.id);
  if (!task) return res.status(404).json({ error: 'Task not found' });
  res.json(task);
});

// POST β€” create a resource (201 + the new object)
app.post('/api/tasks', (req, res) => {
  const task = createTask(req.body);
  res.status(201).json(task);
});

// PUT β€” replace a resource entirely
app.put('/api/tasks/:id', (req, res) => {
  const task = replaceTask(req.params.id, req.body);
  res.json(task);
});

// PATCH β€” update only the fields sent
app.patch('/api/tasks/:id', (req, res) => {
  const task = updateTask(req.params.id, req.body);
  res.json(task);
});

// DELETE β€” remove a resource (204, no body)
app.delete('/api/tasks/:id', (req, res) => {
  deleteTask(req.params.id);
  res.status(204).send();
});

PUT vs PATCH: the difference that trips people up

PUT replaces the whole resource β€” anything you omit is wiped. PATCH merges only the fields you send, leaving the rest untouched. Given a starting task { id: 7, title: "Ship", done: false, priority: "high" }:

// PUT /api/tasks/7  with body { "title": "Ship v2", "done": true }
// Result: { id: 7, title: "Ship v2", done: true, priority: null }
//         ^ priority was dropped because PUT replaces everything

// PATCH /api/tasks/7  with body { "title": "Ship v2", "done": true }
// Result: { id: 7, title: "Ship v2", done: true, priority: "high" }
//         ^ priority survives because PATCH only touches sent fields
flowchart LR A[Original resource] --> B{PUT or PATCH?} B -->|PUT| C[Replace whole resource] B -->|PATCH| D[Merge sent fields only] C --> E[Omitted fields reset] D --> F[Omitted fields preserved]

Designing Resource URIs

A good URI reads like a location, not a command. The action lives in the HTTP method; the URI only says which resource. Follow a few conventions and your API becomes guessable.

βœ… Do

  • Use nouns, never verbs: /api/users, not /api/getUsers
  • Use plural collection names: /api/users, not /api/user
  • Keep them lowercase with hyphens for multi-word names: /api/blog-posts
  • Nest to show relationships: /api/users/:id/orders

⚠️ Avoid

  • /api/getUsers β€” the verb belongs to GET
  • /api/user/7 β€” inconsistent singular/plural
  • /api/userManagement β€” abstract, not a resource
  • /api/blogPosts β€” camelCase in a URL

Path parameters vs query parameters

Use path parameters to identify a specific resource, and query parameters to filter, sort, or paginate a collection. The rule of thumb: if removing it would point you at a different thing, it's a path parameter; if it only changes which slice of the same collection you see, it's a query parameter.

// Path parameter identifies ONE task
app.get('/api/tasks/:id', getTaskById);

// Query parameters filter, sort, and paginate the SAME collection
app.get('/api/tasks', (req, res) => {
  const { status, sort = 'createdAt', page = 1, limit = 10 } = req.query;

  let result = tasks;
  if (status) result = result.filter(t => t.status === status);

  result = [...result].sort((a, b) =>
    String(a[sort]).localeCompare(String(b[sort]))
  );

  const start = (Number(page) - 1) * Number(limit);
  const pageItems = result.slice(start, start + Number(limit));

  res.json({
    data: pageItems,
    meta: {
      total: result.length,
      page: Number(page),
      limit: Number(limit),
      pages: Math.ceil(result.length / Number(limit)),
    },
  });
});
πŸ’‘ Postal analogy: The path parameter is the street address β€” it uniquely names the destination. Query parameters are the delivery instructions ("leave at back door", "signature required"). They change how you get the result, not which building you're delivering to.

Status Codes That Tell the Truth

The status code is the first thing a client reads. Getting it right means clients (and humans debugging with the network tab) instantly understand what happened. Return the most specific accurate code β€” not 200 for everything.

CodeMeaningWhen to use it
200 OKSuccessGET, or an update that returns the resource
201 CreatedResource createdSuccessful POST β€” return the new resource
204 No ContentSuccess, empty bodySuccessful DELETE, or an update returning nothing
400 Bad RequestMalformed requestMissing/invalid fields, bad JSON
401 UnauthorizedNot authenticatedMissing or invalid credentials
403 ForbiddenNot allowedAuthenticated, but lacks permission
404 Not FoundNo such resourceThe URI points to nothing
409 ConflictState clashDuplicate email, version conflict
422 UnprocessableSemantic errorWell-formed but fails validation rules
500 Server ErrorWe brokeUnhandled exception on the server
app.post('/api/users', (req, res) => {
  // 400 β€” the request itself is malformed
  if (!req.body.email) {
    return res.status(400).json({ error: 'Email is required' });
  }
  // 409 β€” a real conflict with existing state
  if (userExists(req.body.email)) {
    return res.status(409).json({ error: 'Email already in use' });
  }
  const user = createUser(req.body);
  // 201 β€” created, and we hand back the new resource
  res.status(201).json(user);
});

⚠️ The classic mistake: 200 with an error body

Returning res.status(200).json({ error: 'Not found' }) forces every client to parse the body just to learn something went wrong β€” and breaks retries, caching, and monitoring, all of which key off the status code. Let the code carry the outcome and the body carry the detail.

Real-world example: Stripe leans heavily on status codes β€” 400 for invalid parameters, 401 for bad keys, 402 for failed payments, 429 for rate limiting β€” each paired with a machine-readable error code. That discipline is a big part of why their API is considered a gold standard.

Structuring an Express API

A single app.js with fifty routes becomes unmaintainable fast. The standard fix is to split concerns: routers declare the URLs, controllers hold the logic, and app.js just wires everything together.

project/
β”œβ”€β”€ app.js                    # wiring: middleware, routes, error handler
β”œβ”€β”€ routes/
β”‚   └── task.routes.js        # URL β†’ controller mapping
β”œβ”€β”€ controllers/
β”‚   └── task.controller.js    # request/response logic
└── middleware/
    └── error-handler.js      # centralized error responses
flowchart LR A[Request] --> B[app.js] B --> C[Router] C --> D[Controller] D --> E[(Data / Model)] D --> F[Response]

The router is thin β€” it only says which controller function handles which URL and method:

// routes/task.routes.js
const express = require('express');
const router = express.Router();
const controller = require('../controllers/task.controller');

router.get('/', controller.getAllTasks);
router.get('/:id', controller.getTaskById);
router.post('/', controller.createTask);
router.put('/:id', controller.replaceTask);
router.patch('/:id', controller.updateTask);
router.delete('/:id', controller.deleteTask);

module.exports = router;

And app.js mounts the router under a base path, adds a catch-all 404, and finishes with the error handler:

// app.js
const express = require('express');
const taskRoutes = require('./routes/task.routes');
const errorHandler = require('./middleware/error-handler');

const app = express();
app.use(express.json());               // parse JSON bodies

app.use('/api/tasks', taskRoutes);     // mount the resource

// 404 for anything unmatched
app.use((req, res) => {
  res.status(404).json({ error: 'Resource not found' });
});

app.use(errorHandler);                 // must be last

app.listen(3000, () => console.log('API on http://localhost:3000'));

πŸ’‘ Why controllers use next(error)

In an async controller, wrap the body in try/catch and pass caught errors to next(error). Express routes them to your error-handling middleware, so every route reports failures the same way instead of each one inventing its own error response. You'll build that handler out fully in the next lessons on validation and error handling.

Worked Example: A Tasks API

Let's put it together. Here's a complete controller for a tasks resource using modern async/await. It assumes a Mongoose-style Task model, but the shape is identical for any data layer.

// controllers/task.controller.js
const Task = require('../models/task.model');

// GET /api/tasks  β€” list with pagination
exports.getAllTasks = async (req, res, next) => {
  try {
    const page = Number(req.query.page) || 1;
    const limit = Number(req.query.limit) || 20;
    const skip = (page - 1) * limit;

    const [tasks, total] = await Promise.all([
      Task.find().skip(skip).limit(limit),
      Task.countDocuments(),
    ]);

    res.status(200).json({
      data: tasks,
      meta: { page, limit, total, pages: Math.ceil(total / limit) },
    });
  } catch (error) {
    next(error);
  }
};

// GET /api/tasks/:id  β€” read one
exports.getTaskById = async (req, res, next) => {
  try {
    const task = await Task.findById(req.params.id);
    if (!task) return res.status(404).json({ error: 'Task not found' });
    res.status(200).json({ data: task });
  } catch (error) {
    next(error);
  }
};

// POST /api/tasks  β€” create
exports.createTask = async (req, res, next) => {
  try {
    const task = await Task.create(req.body);
    res.status(201).json({ data: task });
  } catch (error) {
    next(error);
  }
};

// PUT /api/tasks/:id  β€” full replace
exports.replaceTask = async (req, res, next) => {
  try {
    const task = await Task.findOneAndReplace(
      { _id: req.params.id },
      req.body,
      { new: true, runValidators: true }
    );
    if (!task) return res.status(404).json({ error: 'Task not found' });
    res.status(200).json({ data: task });
  } catch (error) {
    next(error);
  }
};

// PATCH /api/tasks/:id  β€” partial update
exports.updateTask = async (req, res, next) => {
  try {
    const task = await Task.findByIdAndUpdate(
      req.params.id,
      { $set: req.body },
      { new: true, runValidators: true }
    );
    if (!task) return res.status(404).json({ error: 'Task not found' });
    res.status(200).json({ data: task });
  } catch (error) {
    next(error);
  }
};

// DELETE /api/tasks/:id  β€” remove
exports.deleteTask = async (req, res, next) => {
  try {
    const task = await Task.findByIdAndDelete(req.params.id);
    if (!task) return res.status(404).json({ error: 'Task not found' });
    res.status(204).send();
  } catch (error) {
    next(error);
  }
};

GET /api/tasks?page=1&limit=2 responds with:

{
  "data": [
    { "_id": "665f...", "title": "Write lesson", "done": false },
    { "_id": "665f...", "title": "Review PR", "done": true }
  ],
  "meta": { "page": 1, "limit": 2, "total": 17, "pages": 9 }
}

Every handler follows the same skeleton: do the work, check for "not found", return the right status. That predictability is what makes a REST API pleasant to consume.

Hands-on Exercise

πŸ‹οΈ Build a Bookmarks API

Objective: Implement a full CRUD REST resource with correct methods and status codes.

Instructions:

  1. Create an Express app with express.json() and an in-memory array let bookmarks = [].
  2. Implement these routes for a /api/bookmarks resource:
    • GET /api/bookmarks β†’ 200 with the list
    • GET /api/bookmarks/:id β†’ 200, or 404 if missing
    • POST /api/bookmarks β†’ 201 with the created bookmark; 400 if url is missing
    • PATCH /api/bookmarks/:id β†’ 200 with the merged bookmark; 404 if missing
    • DELETE /api/bookmarks/:id β†’ 204; 404 if missing
  3. Test each route with curl or your REST client and confirm the status codes.
πŸ’‘ Hint

Generate ids with crypto.randomUUID(). For PATCH, find the item, then use Object.assign(existing, req.body) to merge only the sent fields. Remember to return after sending an error response so the handler doesn't keep running.

βœ… Solution
const express = require('express');
const { randomUUID } = require('crypto');

const app = express();
app.use(express.json());

let bookmarks = [];

app.get('/api/bookmarks', (req, res) => {
  res.status(200).json({ data: bookmarks });
});

app.get('/api/bookmarks/:id', (req, res) => {
  const bookmark = bookmarks.find(b => b.id === req.params.id);
  if (!bookmark) return res.status(404).json({ error: 'Not found' });
  res.status(200).json({ data: bookmark });
});

app.post('/api/bookmarks', (req, res) => {
  if (!req.body.url) {
    return res.status(400).json({ error: 'url is required' });
  }
  const bookmark = { id: randomUUID(), ...req.body };
  bookmarks.push(bookmark);
  res.status(201).json({ data: bookmark });
});

app.patch('/api/bookmarks/:id', (req, res) => {
  const bookmark = bookmarks.find(b => b.id === req.params.id);
  if (!bookmark) return res.status(404).json({ error: 'Not found' });
  Object.assign(bookmark, req.body);
  res.status(200).json({ data: bookmark });
});

app.delete('/api/bookmarks/:id', (req, res) => {
  const index = bookmarks.findIndex(b => b.id === req.params.id);
  if (index === -1) return res.status(404).json({ error: 'Not found' });
  bookmarks.splice(index, 1);
  res.status(204).send();
});

app.listen(3000, () => console.log('http://localhost:3000'));

Best Practices

βœ… Do

  • Prefix all API routes with /api (and consider /api/v1) so you can evolve them later.
  • Return the created/updated resource from POST/PUT/PATCH so the client doesn't need a second request.
  • Use plural nouns and nest only one or two levels deep.
  • Keep controllers thin β€” push shared logic into services/models.
  • Send a consistent response envelope (e.g. always { data, meta }).

❌ Don't

  • Don't put verbs in URLs (/api/createTask) β€” the method is the verb.
  • Don't return 200 for errors, and don't leak stack traces to clients in production.
  • Don't deeply nest (/users/:id/orders/:oid/items/:iid/...) β€” flatten with top-level resources.
  • Don't repeat error-response code in every handler β€” centralize it in error-handling middleware.

πŸ’‘ A word on versioning

When a breaking change is unavoidable, version the API rather than break existing clients. The most common approach is a path prefix (/api/v1, /api/v2), each mounted on its own router. Header-based (Accept-Version) and media-type versioning exist too, but path versioning is the easiest to reason about and to cache.

Summary & Quiz

πŸŽ‰ Key Takeaways

  • REST models your app as resources addressed by URIs and manipulated with standard HTTP methods.
  • GET/POST/PUT/PATCH/DELETE map to Read/Create/Replace/Update/Delete; PUT replaces, PATCH merges.
  • Design URIs as plural nouns; use path params to identify and query params to filter/sort/paginate.
  • Return the most specific accurate status code β€” never 200 for errors.
  • Split an Express API into routers, controllers, and middleware, and pass errors to next().

🎯 Quick Quiz

Question 1: A client sends a request that omits several fields but wants the other fields left unchanged. Which method should the API use?

Question 2: Which URI best follows REST naming conventions for "the orders belonging to user 42"?

Question 3: A POST to create a resource succeeds. What status code should the response carry?

πŸ“š Further Reading

πŸš€ What's Next?

Your API now accepts requests β€” but it trusts every byte the client sends. Next we'll add request validation and sanitization so bad or malicious input never reaches your business logic.

πŸŽ‰ Well done!

You can now design and build a clean, predictable REST API in Express. That skeleton scales to almost any resource you'll ever model.