Skip to content
Express.js ex api 5 min read

RESTful Routes & CRUD

A RESTful API models your application as a collection of resources — nouns like users, articles, or orders — and uses HTTP verbs to express the operations performed on them. Instead of inventing endpoint names like /getUser or /deleteArticle, you point every verb at a consistent URL and let GET, POST, PUT, PATCH, and DELETE carry the intent. This page builds the complete set of CRUD routes for a single resource and explains the semantics that separate a well-behaved API from one that merely works.

The standard route table

For any resource there is a canonical mapping between HTTP method, URL, and the CRUD operation it performs. Sticking to this table means clients can predict your API without reading documentation for every endpoint.

MethodPathOperationTypical success status
GET/articlesList the collection200 OK
POST/articlesCreate a new article201 Created
GET/articles/:idRead one article200 OK
PUT/articles/:idReplace an article fully200 OK
PATCH/articles/:idUpdate an article partially200 OK
DELETE/articles/:idRemove an article204 No Content

Notice there are only two URLs — the collection (/articles) and the member (/articles/:id). The method, not the path, decides what happens.

Building the router

Group every route for the resource into a single express.Router() and mount it under the resource prefix. Using async handlers keeps database calls readable, and a try/catch forwards failures to your error middleware via next(err).

// routes/articles.js
const express = require("express");
const router = express.Router();
const db = require("../db");

// LIST  →  GET /articles
router.get("/", async (req, res, next) => {
  try {
    const articles = await db.articles.findAll();
    res.json(articles);
  } catch (err) {
    next(err);
  }
});

// CREATE  →  POST /articles
router.post("/", async (req, res, next) => {
  try {
    const { title, body } = req.body;
    if (!title) return res.status(400).json({ error: "title is required" });

    const article = await db.articles.create({ title, body });
    res.status(201).location(`/articles/${article.id}`).json(article);
  } catch (err) {
    next(err);
  }
});

// READ  →  GET /articles/:id
router.get("/:id", async (req, res, next) => {
  try {
    const article = await db.articles.findById(req.params.id);
    if (!article) return res.status(404).json({ error: "Not found" });
    res.json(article);
  } catch (err) {
    next(err);
  }
});

module.exports = router;

Mount it in your entry file and enable the JSON body parser so req.body is populated:

// app.js
const express = require("express");
const app = express();

app.use(express.json());
app.use("/articles", require("./routes/articles"));

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

A create request returns the new resource together with a Location header pointing at it:

Output:

$ curl -i -X POST http://localhost:3000/articles \
    -H "Content-Type: application/json" \
    -d '{"title":"REST in Express","body":"..."}'

HTTP/1.1 201 Created
Location: /articles/42
Content-Type: application/json

{"id":42,"title":"REST in Express","body":"..."}

Replacing vs. updating: PUT and PATCH

The two update verbs are not interchangeable, and confusing them is one of the most common REST mistakes. PUT replaces the entire resource with the representation in the request body — any field you omit is treated as cleared or reset, not left untouched. PATCH applies a partial change, modifying only the fields you send and leaving the rest as they are.

// REPLACE  →  PUT /articles/:id
router.put("/:id", async (req, res, next) => {
  try {
    const { title, body } = req.body;
    if (!title) return res.status(400).json({ error: "title is required" });

    // The full object is supplied; missing fields become null/default.
    const article = await db.articles.replace(req.params.id, { title, body });
    if (!article) return res.status(404).json({ error: "Not found" });
    res.json(article);
  } catch (err) {
    next(err);
  }
});

// UPDATE  →  PATCH /articles/:id
router.patch("/:id", async (req, res, next) => {
  try {
    // Only the provided keys are changed; everything else is preserved.
    const article = await db.articles.update(req.params.id, req.body);
    if (!article) return res.status(404).json({ error: "Not found" });
    res.json(article);
  } catch (err) {
    next(err);
  }
});

Gotcha: Because PUT is a full replacement, sending PUT /articles/42 with only { "title": "New" } should wipe out the body. If your handler quietly preserves omitted fields, you have actually implemented PATCH semantics under a PUT name — clients relying on replacement will be surprised.

Idempotency

An operation is idempotent when making the same request many times has the same effect on server state as making it once. This property lets clients safely retry after a dropped connection without fear of duplicating work.

MethodIdempotent?Why
GETYesReads never change state.
PUTYesReplacing with the same body lands on the same final state.
DELETEYesDeleting an already-deleted resource leaves it deleted.
PATCHNot guaranteedDepends on the patch (e.g. “increment by 1” is not idempotent).
POSTNoEach call typically creates a new resource.

The DELETE handler illustrates idempotency directly. Whether the article existed or not, repeated calls converge on the same outcome — the resource is gone.

// DELETE  →  DELETE /articles/:id
router.delete("/:id", async (req, res, next) => {
  try {
    await db.articles.remove(req.params.id);
    res.status(204).end(); // 204 No Content — empty body
  } catch (err) {
    next(err);
  }
});

Tip: Returning 204 No Content for a successful DELETE keeps it idempotent even when the row was already absent. Returning 404 on a second delete is also defensible, but many teams prefer the smoother 204 so retries never look like failures.

Express 5 note

In Express 5, route handlers that return a rejected promise are forwarded to your error-handling middleware automatically, so the try/catch wrappers above become optional. They remain necessary on Express 4.x. The CRUD routing model itself — collection and member URLs driven by HTTP verbs — is identical across both versions.

Best Practices

  • Use plural nouns for collections (/articles, not /article) and never put verbs in the path.
  • Return 201 Created with a Location header on POST, and 204 No Content on DELETE.
  • Honor the contract: make PUT a full replacement and PATCH a partial update — do not blur them.
  • Validate the request body up front and respond with 400 Bad Request before touching the database.
  • Return 404 Not Found for member routes when the :id does not resolve, rather than a generic error.
  • Keep PUT and DELETE idempotent so clients can retry safely on network failures.
  • Forward errors with next(err) (Express 4) or rely on automatic promise rejection forwarding (Express 5) instead of handling each one inline.
Last updated June 14, 2026
Was this helpful?