Skip to main content

Command Palette

Search for a command to run...

REST APIs: What Your Frontend Is Actually Saying to Your Backend

Updated
11 min read
REST APIs: What Your Frontend Is Actually Saying to Your Backend
S
Trying to transition my career to explore new things, new tech

I spent an embarrassing amount of time building my first full-stack app before I understood what was happening between the browser and the server. I had a React frontend. I had an Express backend. They talked to each other. But if you had asked me how they talked, or what the rules were, I would have mumbled something about fetch and JSON and changed the subject.

Then someone explained REST to me in about ten minutes, and suddenly half the confusing stuff I had been copying from tutorials made sense. So that is what this post is.


APIs are just agreements

When your frontend needs data, it sends a request to your backend. The backend sends a response. That exchange is an API call. API stands for Application Programming Interface, which sounds complicated but the idea is boring: two programs agreed on how to talk to each other.

Think of it like ordering food. You do not walk into the kitchen and start cooking. You talk to the waiter. You say what you want in a format they understand (the menu), they bring back what you ordered. The waiter is the API. The kitchen is the server. You are the client.

REST is one specific set of rules for how that conversation should work. It stands for Representational State Transfer. The name is not helpful. What matters is the pattern: you use standard HTTP methods (GET, POST, PUT, DELETE) to perform actions on resources, and the server responds with data and a status code telling you what happened.

Most APIs you will use as a beginner follow REST conventions. Once you learn the pattern, every new API you encounter feels familiar.


Resources: the nouns of your API

In REST, everything revolves around resources. A resource is any piece of data your application manages. Users, posts, comments, products, orders. Each one is a resource.

The way you identify a resource is through its URL (or more formally, its URI). And the naming convention is simple: use nouns, not verbs. Plural nouns.

/users          (the whole collection of users)
/users/42       (one specific user, ID 42)
/posts          (all posts)
/posts/7        (post number 7)

You do not put actions in the URL. No /getUser, no /deletePost, no /createNewComment. The HTTP method (GET, POST, PUT, DELETE) already tells the server what action to perform. The URL just says what thing you are acting on.

This is the part that clicked for me late. I kept naming routes like /api/getUserById and /api/createUser because that felt natural. It works, but it falls apart once your app has 30 endpoints and none of them follow a pattern. REST gives you the pattern.


HTTP methods: the verbs

There are four methods you will use constantly. Each one maps to a CRUD operation (Create, Read, Update, Delete), which is the set of basic actions almost every application needs.

HTTP Method CRUD Operation What it does Example
GET Read Fetch data, change nothing GET /users
POST Create Send new data to the server POST /users
PUT Update Replace existing data PUT /users/42
DELETE Delete Remove data DELETE /users/42

That is the whole mapping. Four methods, four operations. Let me walk through each one with actual Express code, because seeing the request and response together is what made this real for me.

GET: give me the data

GET requests fetch data. They should never change anything on the server. If a GET request modifies data, something is wrong with your design. This is called being "safe" in REST terminology.

// GET /users - fetch all users
app.get('/users', async (req, res) => {
  const users = await User.find();
  res.status(200).json(users);
});

// GET /users/42 - fetch one user
app.get('/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  res.status(200).json(user);
});

The response for a list of users looks like:

[
  { "_id": "abc123", "name": "Priya", "email": "priya@example.com" },
  { "_id": "def456", "name": "Ravi", "email": "ravi@example.com" }
]

Notice the route /users/:id. The colon means id is a parameter. When someone hits /users/42, Express puts "42" into req.params.id. You use that to look up one specific resource.

POST: create something new

POST sends data to the server to create a new resource. The data goes in the request body, not the URL.

// POST /users - create a new user
app.post('/users', async (req, res) => {
  try {
    const user = await User.create(req.body);
    res.status(201).json(user);
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});

The request body (what the client sends) would look like:

{
  "name": "Meera",
  "email": "meera@example.com",
  "age": 24
}

And the response comes back with the created user, including the _id that MongoDB generated:

{
  "_id": "ghi789",
  "name": "Meera",
  "email": "meera@example.com",
  "age": 24,
  "createdAt": "2025-01-15T10:30:00Z"
}

Status 201 means "created." Not 200. This is a small detail that a lot of beginners skip, and it matters because status codes tell the client exactly what happened without parsing the response body.

PUT: replace something that exists

PUT updates an existing resource. You send the full updated version, and the server replaces what was there before.

// PUT /users/42 - update user 42
app.put('/users/:id', async (req, res) => {
  const user = await User.findByIdAndUpdate(
    req.params.id,
    req.body,
    { new: true, runValidators: true }
  );
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  res.status(200).json(user);
});

{ new: true } tells Mongoose to return the document after the update, not before. Without it you get back the old version, which is confusing the first time it happens.

There is also PATCH, which updates only specific fields instead of replacing the whole document. In practice, many APIs use PUT and PATCH interchangeably, which is technically wrong but extremely common. When you are starting out, PUT is fine. You can worry about the distinction later.

DELETE: remove something

DELETE removes a resource. The response usually confirms what was deleted or just returns an empty success.

// DELETE /users/42 - delete user 42
app.delete('/users/:id', async (req, res) => {
  const user = await User.findByIdAndDelete(req.params.id);
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  res.status(200).json({ message: 'User deleted' });
});

Some APIs return 204 (No Content) with an empty body on successful deletion. Either approach is fine as long as you pick one and stick with it across your whole API.


Status codes: what the numbers mean

You have already seen a few of these. Status codes are three-digit numbers the server sends back to tell the client how things went. You do not need to memorize all of them. The groupings are what matter.

Range Category Meaning
2xx Success The request worked
4xx Client error The request had a problem (your fault)
5xx Server error The server broke while handling it (our fault)

The ones you will use in almost every project:

200 means OK. The request succeeded. Used for GET, PUT, and DELETE responses.

201 means Created. A new resource was made. Used after a successful POST.

400 means Bad Request. The client sent something invalid, like missing required fields or a malformed email.

401 means Unauthorized. The client is not logged in or the auth token is missing.

403 means Forbidden. The client is logged in but does not have permission for this action.

404 means Not Found. The resource does not exist at that URL.

500 means Internal Server Error. Something crashed on the server. The client did nothing wrong.

A quick way to think about 401 vs 403: 401 is "who are you?" and 403 is "I know who you are, and no."


Route design: putting it together

Here is the full set of routes for a users resource, laid out the way a REST API expects them:

GET    /api/users          Fetch all users
GET    /api/users/:id      Fetch one user by ID
POST   /api/users          Create a new user
PUT    /api/users/:id      Update a user by ID
DELETE /api/users/:id      Delete a user by ID

Five routes. One resource. Consistent naming. If you add a posts resource later, the pattern is identical:

GET    /api/posts
GET    /api/posts/:id
POST   /api/posts
PUT    /api/posts/:id
DELETE /api/posts/:id

You can nest resources too. If posts belong to users:

GET    /api/users/:userId/posts       All posts by user
POST   /api/users/:userId/posts       Create a post for a user

Keep nesting to one level deep. /api/users/:userId/posts/:postId/comments/:commentId is technically valid but miserable to work with. If you find yourself going three levels deep, flatten it out.


A complete working example

This is all five user routes in one file, using Express and Mongoose, with proper status codes and error handling. You can drop this into a project and it works.

const express = require('express');
const router = express.Router();
const User = require('../models/User');

// GET /api/users
router.get('/', async (req, res) => {
  try {
    const users = await User.find().select('name email role');
    res.status(200).json(users);
  } catch (err) {
    res.status(500).json({ error: 'Failed to fetch users' });
  }
});

// GET /api/users/:id
router.get('/:id', async (req, res) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) return res.status(404).json({ error: 'User not found' });
    res.status(200).json(user);
  } catch (err) {
    if (err.name === 'CastError') {
      return res.status(400).json({ error: 'Invalid user ID format' });
    }
    res.status(500).json({ error: 'Failed to fetch user' });
  }
});

// POST /api/users
router.post('/', async (req, res) => {
  try {
    const user = await User.create(req.body);
    res.status(201).json(user);
  } catch (err) {
    if (err.name === 'ValidationError') {
      const messages = Object.values(err.errors).map(e => e.message);
      return res.status(400).json({ error: messages.join(', ') });
    }
    if (err.code === 11000) {
      return res.status(409).json({ error: 'Email already exists' });
    }
    res.status(500).json({ error: 'Failed to create user' });
  }
});

// PUT /api/users/:id
router.put('/:id', async (req, res) => {
  try {
    const user = await User.findByIdAndUpdate(
      req.params.id,
      req.body,
      { new: true, runValidators: true }
    );
    if (!user) return res.status(404).json({ error: 'User not found' });
    res.status(200).json(user);
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});

// DELETE /api/users/:id
router.delete('/:id', async (req, res) => {
  try {
    const user = await User.findByIdAndDelete(req.params.id);
    if (!user) return res.status(404).json({ error: 'User not found' });
    res.status(200).json({ message: 'User deleted' });
  } catch (err) {
    res.status(500).json({ error: 'Failed to delete user' });
  }
});

module.exports = router;

In your main server.js, mount it like this:

const userRoutes = require('./routes/users');
app.use('/api/users', userRoutes);

Now every route inside the file is prefixed with /api/users automatically. The router.get('/') becomes GET /api/users, and router.get('/:id') becomes GET /api/users/:id. Clean.


Things I wish someone told me earlier

Keep your route files separate from your logic. The route handler should be short. If your POST /users handler is 60 lines long with validation, database calls, email sending, and logging all mixed together, pull the logic into a controller or service function. The route file should read like a table of contents.

Use express.json() middleware. Without it, req.body is undefined on POST and PUT requests because Express does not parse JSON by default.

app.use(express.json());

I forgot this once and spent 40 minutes wondering why my request body was always empty. The server was fine. The database was fine. The client was sending data. Express just was not reading it.

Test your API with a tool like Postman, Insomnia, or even curl from the terminal. Testing through the browser only works for GET requests. You need a tool that lets you set the HTTP method, add headers, and include a JSON body.

# quick test from the terminal
curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Priya", "email": "priya@example.com"}'

References