Skip to main content

Command Palette

Search for a command to run...

REST API Design Made Simple with Express.js

Updated
9 min read
REST API Design Made Simple with Express.js

Let me tell you how I built my very first API.

I needed a way to manage users, so I opened up my Express server.js file and wrote these exact routes:

  • app.post('/createNewUser')

  • app.get('/getAllUsers')

  • app.post('/updateUserPassword')

  • app.post('/deleteUser')

It worked perfectly. I pushed it to production. A week later, a frontend developer sent me a Slack message: "Hey, I'm trying to integrate your API. Where is the documentation? I can't guess what your URLs are named."

They were right. My API was a chaotic, unpredictable mess. If someone wanted to fetch a user, did I name it /getUser, /fetchUser, or /retrieveUser? The frontend team had to memorize every single unique URL I had invented.

This is the exact problem REST was invented to solve.

REST (Representational State Transfer) is not a piece of software you install. It isn't a library. It is a strict architectural philosophy. It is a set of universal agreements. When you follow REST, another developer can look at your API and instantly know exactly how to use it without ever reading a manual.

Today, we are going deep into the art of API design. We are going to unlearn bad habits, explore the semantic power of HTTP, and tackle the hardest architectural decisions in backend routing.

The Golden Rule: Nouns, Not Verbs

The most fundamental shift in REST is how you think about your URLs.

In my terrible first API, my URLs were Actions (verbs). I was treating my URLs like JavaScript function names.

In REST, a URL must only represent a Resource (a noun). A resource is a physical thing in your database: a User, an Article, a Comment, a Product.

You should never put an action word in a URL.

  • Bad: http://api.com/getUsers

  • Bad: http://api.com/deleteUser/123

  • Perfect: http://api.com/users/123

Wait, if the URL is just http://api.com/users/123, how does the server know if I want to read that user or delete that user?

The Verbs: Semantic HTTP Methods

We separate the Resource (the URL) from the Action (the HTTP Method).

Every time a browser or a frontend app sends a network request, it attaches an HTTP Method to it. REST uses these methods to determine what happens to the noun.

If we agree that our resource is /users, here is how the entire CRUD (Create, Read, Update, Delete) lifecycle is mapped in Express:

// 1. CREATE: Use POST to add a new resource to the collection
app.post('/users', (req, res) => {
  // Read req.body and save to database...
});

// 2. READ (All): Use GET on the plural noun to fetch the list
app.get('/users', (req, res) => {
  // Fetch all users...
});

// 3. READ (One): Use GET with a specific ID parameter
app.get('/users/:id', (req, res) => {
  // Fetch specifically user req.params.id...
});

// 4. DELETE: Use DELETE with a specific ID parameter
app.delete('/users/:id', (req, res) => {
  // Destroy user req.params.id...
});

Look at how beautiful and predictable that is. The URL is always just /users or /users/:id. The Method dictates the behavior. Any developer in the world knows that sending a DELETE request to /users/99 will destroy User 99. No documentation required.

The Hard Part: PUT vs. PATCH (Idempotency)

Let's talk about Updating data. This is where even mid-level developers stumble, and it is a classic senior engineering interview question.

There are two different HTTP methods used for updating resources: PUT and PATCH. They are not interchangeable. To understand the difference, you must understand a deep computer science concept called Idempotency.

An action is "idempotent" if doing it once has the exact same result as doing it 10,000 times in a row.

  • Toggling a power button is NOT idempotent. (Press it once, the TV turns on. Press it twice, the TV turns off. The state changed.)

  • Pressing the "Channel 5" button IS idempotent. (Press it once, you go to Channel 5. Press it 10,000 times, you are still on Channel 5).

The PUT Method (Total Replacement - Idempotent)PUT completely replaces the target resource with the new payload you send. If User 99 currently has a { name: "Alex", age: 30, city: "Seattle" }, and you send a PUT request with just { name: "Alex" }, the database will overwrite the entire document. The age and city will be permanently deleted. Because it is a total overwrite, PUT is idempotent. Sending that exact payload 100 times leaves the database in the exact same state as sending it once.

The PATCH Method (Partial Modification - Non-Idempotent)PATCH is a laser scalpel. It only modifies the specific fields you send, leaving the rest of the document untouched. If you send a PATCH request with { city: "Portland" }, the database keeps the name and age intact, and only updates the city.

// PUT: The bulldozer. Expects a complete user object in req.body.
app.put('/users/:id', async (req, res) => {
  const updatedUser = await db.users.replaceOne({ id: req.params.id }, req.body);
  res.json(updatedUser);
});

// PATCH: The scalpel. Expects just a few fields in req.body.
app.patch('/users/:id', async (req, res) => {
  const updatedUser = await db.users.updateOne({ id: req.params.id }, { $set: req.body });
  res.json(updatedUser);
});

If you are just building standard web forms where a user updates their email address but leaves their profile picture alone, you almost always want to use PATCH.

The Traps: Nested Resources & The Depth Limit

Once you master Nouns and Verbs, you run into the next architectural nightmare: Relationships.

How do you fetch the comments written by a specific user on a specific post? Junior developers usually try to reflect the entire database hierarchy in the URL. They build monsters like this:

GET /users/123/posts/456/comments/789

This is a massive REST anti-pattern. URLs are not meant to be deep directory trees. Long, deeply nested URLs are incredibly fragile, hard to parse, and almost impossible to maintain.

The Golden Rule of Nesting: Never go deeper than two levels.

When you hit the third level, you are doing it wrong. The secret is to realize that once you have the ID of a specific resource, you no longer need its parents in the URL.

If I know the comment ID is 789, I don't need to know the User ID or the Post ID to find it in the database. I can just ask the database for Comment 789 directly!

Here is how you flatten the hierarchy using clean REST principles:

  • Fetch all posts by a user: GET /users/123/posts (Acceptable: 2 levels deep)

  • Fetch a specific post: GET /posts/456 (Flattened! We dropped the user because we have the post ID)

  • Fetch comments on a post: GET /posts/456/comments (Acceptable: 2 levels deep)

  • Fetch a specific comment: GET /comments/789 (Flattened!)

If you need to filter data without deep nesting, use Query Strings (which we covered in a previous post!): GET /comments?userId=123&postId=456

The Communicator: HTTP Status Codes

You performed the action. Now, how do you tell the frontend what happened?

The absolute worst thing you can do as a backend engineer is send an error message, but attach a 200 OK status code to it.

// A REST War Crime
app.post('/users', (req, res) => {
  // The database crashed!
  res.status(200).json({ error: "Failed to create user." }); // DONT DO THIS
});

Browsers, Load Balancers, and frontend libraries (like React Query or Axios) do not read your JSON payload first. They read the raw HTTP Status Code. If you send a 200 OK, the frontend blindly assumes the request was a massive success, caches the data, and proceeds.

You must use the correct semantic status codes. This is how your API speaks to the outside world.

The Essential Vocabulary:

  • 200 OK: Standard success. (Used for GET, PUT, PATCH).

  • 201 Created: Success, and a new resource was built. (Used primarily for POST).

  • 204 No Content: Success, but I have no data to send back. (Used heavily for DELETE).

  • 400 Bad Request: The frontend messed up. They sent missing or invalid data.

  • 401 Unauthorized: The user has no JWT / isn't logged in.

  • 403 Forbidden: The user is logged in, but they don't have Admin rights to do this.

  • 404 Not Found: The URL or the Resource ID doesn't exist.

  • 500 Internal Server Error: The backend messed up. The database crashed or the code threw an exception.

app.get('/users/:id', async (req, res) => {
  try {
    const user = await db.find(req.params.id);

    if (!user) {
      // Speak clearly! The resource wasn't found.
      return res.status(404).json({ error: "User does not exist" });
    }

    // Speak clearly! Success.
    res.status(200).json(user);

  } catch (err) {
    // Speak clearly! My server broke.
    res.status(500).json({ error: "Database connection failed" });
  }
});

The Future-Proofing: API Versioning

I will leave you with one final, critical piece of architecture.

One day, you will realize your database schema is terrible. You will want to change how /users returns data. If you just change the code and push it to production, every single mobile app and frontend client currently relying on the old data structure will instantly crash.

You cannot break existing contracts.

To prevent this, you must Version your API from day one. You simply prefix all of your Express routes with /api/v1/.

const express = require('express');
const app = express();
const v1Router = express.Router();

v1Router.get('/users', (req, res) => { /* Old logic */ });

app.use('/api/v1', v1Router);

When it is time to make breaking changes a year from now, you don't delete v1. You create a brand new v2Router, build the new features, and mount it at /api/v2/.

The old mobile apps continue to use v1 safely, while your new web dashboard uses v2. You have successfully protected your production environment.

The Takeaway

REST is not a strict law enforced by your computer. Your server will not crash if you name a route /deleteUser.

REST is a language. It is a set of cultural norms shared by millions of engineers around the world. When you use Nouns instead of Verbs, when you respect the idempotency of PUT vs. PATCH, and when you communicate accurately using Status Codes, your API becomes a joy to consume.

You stop being a programmer who just makes things work, and you become an architect who designs systems meant to last.