DEV Community

Cover image for How Frontend Talks to Backend — REST, JSON, and CORS Explained Without the Jargon
Prathamesh Dhadbale
Prathamesh Dhadbale

Posted on

How Frontend Talks to Backend — REST, JSON, and CORS Explained Without the Jargon

You've got a React app on one side. An Express server on the other. And you know you need to connect them somehow.

But then come the questions — what exactly is an API? What makes it "REST"? Why does everyone use JSON? And why does CORS lose its mind the moment you try to fetch something?

Let's answer all of it.


First — what even is an API?

API stands for Application Programming Interface. But forget the full form, here's what it actually means:

Your frontend is a customer. Your backend is a restaurant kitchen. The kitchen doesn't let customers walk in and grab food themselves. Instead there's a menu — a fixed list of things you can order, with specific names and rules.

That menu is your API.

Your backend doesn't expose its database directly to the frontend. Instead it says — "here are the URLs you can call, here's what you send me, here's what I'll send back." The frontend doesn't know or care how the backend stores data internally. It just knows the menu and orders from it.


What makes an API "REST"?

REST is not a library. Not a framework. Not a technology you install.

It's a convention — an agreed-upon style for designing your API so that anyone can understand it without reading a manual.

The core idea is simple:

  • URLs represent things (called resources) — /jobs, /users, /posts
  • HTTP methods represent actions on those things
GET    /jobs        → fetch all jobs
POST   /jobs        → create a new job
PUT    /jobs/1      → fully update job with id 1
PATCH  /jobs/1      → partially update job with id 1
DELETE /jobs/1      → delete job with id 1
Enter fullscreen mode Exit fullscreen mode

Notice the difference between PUT and PATCH — PUT means replace the entire resource with new data, PATCH means only update the specific fields you send and leave everything else as it is.

And notice how the URL only describes what you're acting on, not what action you're doing. The HTTP method handles the action. So /getJobs is wrong REST style — it should just be GET /jobs.

That's REST. A clean, readable, predictable way to structure your API.


Status codes — the response's mood indicator

Every response your backend sends comes with a status code — a number that tells the frontend how things went. Think of it as the server's emotional state.

200 OK              → worked perfectly
201 Created         → new resource was created successfully
400 Bad Request     → you sent something wrong
401 Unauthorized    → you're not logged in
403 Forbidden       → you're logged in but not allowed
404 Not Found       → that resource doesn't exist
500 Internal Error  → something broke on the server side
Enter fullscreen mode Exit fullscreen mode

A status code is not the same as your response data. You can send a 200 with wrong data, or a 404 with a helpful error message. Always set both intentionally.

res.status(201).json({ id: 1, company: 'Stripe' }); // created
res.status(404).json({ error: 'Job not found' });    // not found
Enter fullscreen mode Exit fullscreen mode

JSON — why everything uses it

When your frontend sends data to the backend or receives it back, both sides need to agree on a format. That format is JSON.

Here's the thing that trips people up — a JavaScript object and a JSON string look almost identical, but they are completely different things:

// JavaScript object — lives in memory, can have methods
const job = { company: 'Stripe', role: 'Intern' }

// JSON string — plain text, travels over the network
'{"company":"Stripe","role":"Intern"}'
Enter fullscreen mode Exit fullscreen mode

A JavaScript object is a living thing in your computer's memory. JSON is just text — a standardized way of representing that object as a string so it can travel over the network.

The network doesn't understand JavaScript objects. It only carries bytes of text. So before sending, you convert:

JSON.stringify(job)   // object → JSON string (before sending)
JSON.parse(jsonStr)   // JSON string → object (after receiving)
Enter fullscreen mode Exit fullscreen mode

On the frontend with fetch, response.json() handles the parsing for you automatically.

Why Content-Type: application/json matters

When you send a request with a JSON body, you must tell the server what format the data is in using a header:

fetch('/api/jobs', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' }, // this line matters
  body: JSON.stringify({ company: 'Stripe' })
})
Enter fullscreen mode Exit fullscreen mode

On the Express side, express.json() middleware reads this header. If it says application/json, it parses the body and puts it in req.body. If the header is missing or wrong — req.body is undefined. That's it. That's the entire reason forgetting this header breaks everything.


How Express reads your request — req.body vs req.params vs req.query

When a request reaches your Express controller, the data can be in three different places depending on how it was sent:

// req.params — data in the URL path
// Route: /jobs/:id
// Request: GET /jobs/42
req.params.id  // → "42"

// req.query — data in the URL after the ?
// Request: GET /jobs?status=applied
req.query.status  // → "applied"

// req.body — data in the request body (POST, PUT, PATCH)
// Requires express.json() middleware
req.body.company  // → "Stripe"
Enter fullscreen mode Exit fullscreen mode

Simple rule — if it's in the URL it's params or query, if it's sent as data in the request body it's req.body.


The fetch API — why two awaits?

When you call fetch(), it doesn't give you your data directly. It gives you a Promise that resolves to a Response object. Then you need a second step to actually read the data:

const res = await fetch('/api/jobs');   // first await — gets the response headers
const data = await res.json();          // second await — reads and parses the body
Enter fullscreen mode Exit fullscreen mode

Why two steps? Because of how the internet works.

The first await resolves the moment your browser receives the HTTP headers and status code from the server. But the actual body — the JSON data — might still be traveling across the network in packets. The second await res.json() waits for all those packets to arrive, assembles them, and parses the complete JSON string into a JavaScript object.

One important thing — fetch() only rejects (throws an error) if the network itself fails. A DNS failure, a dropped connection, a CORS block. If the server responds with a 404 or 500, fetch() still resolves successfully — because the network transaction worked, the server just returned an error status. This is why you need to check manually:

const res = await fetch('/api/jobs');

if (!res.ok) {
  // res.ok is true for 200-299, false for everything else
  throw new Error(`Request failed: ${res.status}`);
}

const data = await res.json();
Enter fullscreen mode Exit fullscreen mode

CORS — the one that confuses everyone

Your React app runs on http://localhost:5173.
Your Express server runs on http://localhost:5000.

Different port = different origin.

Browsers have a built-in security rule called the Same-Origin Policy — a webpage cannot read responses from a different origin than the one it was served from. This exists to protect you. Without it, any random website you visit could silently make requests to your bank using your logged-in session and read your account data.

But in development you need your frontend to talk to your backend. That's what CORS solves — Cross-Origin Resource Sharing. It's a way for the server to say "yes, I trust this other origin, let it read my responses."

import cors from 'cors';

// Allow only your frontend
app.use(cors({ origin: 'http://localhost:5173' }));

// Allow everyone (fine for public APIs, dangerous for authenticated ones)
app.use(cors({ origin: '*' }));
Enter fullscreen mode Exit fullscreen mode

The thing that makes CORS click

Here's what almost no tutorial explains clearly:

CORS is enforced by the browser, not the server.

When you make a cross-origin request, the request actually reaches the server. The server processes it and sends back a response. But then the browser intercepts that response, checks the Access-Control-Allow-Origin header, and if it doesn't see your frontend's origin listed — it throws the response away and shows you a CORS error.

You never got blocked from sending. You got blocked from reading the response.

This is why Postman never gets CORS errors. Postman is not a browser. It has no Same-Origin Policy. It sends the request, gets the response, and shows it to you directly — no security checks. The CORS error is the browser protecting the user, not the server rejecting you.

Preflight requests — why you sometimes see two requests

For requests that could modify data (POST, PUT, DELETE) or use custom headers, the browser doesn't just send your request straight away. It first sends a quick OPTIONS request — called a preflight — to ask the server "hey, are you okay with me sending this from this origin?"

OPTIONS /api/jobs HTTP/1.1
Origin: http://localhost:5173
Access-Control-Request-Method: POST
Enter fullscreen mode Exit fullscreen mode

The server responds with what it allows. If the answer is yes, the browser then sends your actual request. If you ever see two requests in your Network tab when you expected one — that's the preflight.


Putting it all together

// Frontend — React component
useEffect(() => {
  fetch('http://localhost:5000/api/jobs')
    .then(res => {
      if (!res.ok) throw new Error('Request failed');
      return res.json();
    })
    .then(data => setJobs(data))
    .catch(err => console.error(err));
}, []);
Enter fullscreen mode Exit fullscreen mode
// Backend — Express server
import cors from 'cors';
import express from 'express';

const app = express();
app.use(express.json());
app.use(cors({ origin: 'http://localhost:5173' }));

app.get('/api/jobs', async (req, res) => {
  const jobs = await pool.query('SELECT * FROM jobs');
  res.status(200).json(jobs.rows);
});
Enter fullscreen mode Exit fullscreen mode

What happens when that useEffect runs:

  1. fetch() sends GET http://localhost:5000/api/jobs
  2. Browser checks — different origin, sends preflight OPTIONS first
  3. Express responds — CORS allowed, proceed
  4. Browser sends the real GET request
  5. Express matches the route, queries the DB, sends back JSON
  6. Browser checks Access-Control-Allow-Origin header — matches, passes response through
  7. res.json() parses the JSON string into a JS array
  8. setJobs(data) re-renders the component with real data

Common mistakes

1. Forgetting Content-Type: application/json in fetch
req.body will be undefined on the server. Always include the header when sending data.

2. Not checking res.ok after fetch
fetch() won't throw on a 404 or 500. You have to check res.ok yourself.

3. Thinking CORS is the server rejecting you
The server processed your request. The browser is blocking you from reading the response. Fix it by adding CORS headers on the server, not by changing your frontend code.

4. Using res.send() instead of res.json() for APIs
res.send() with a string sets Content-Type: text/html. Use res.json() which always enforces application/json — that's what your frontend expects.

5. Putting CORS middleware after your routes
Middleware runs in order. If CORS is registered after your routes, it never runs for those routes.

// Wrong
app.get('/api/jobs', getJobs);
app.use(cors()); // too late

// Right
app.use(cors()); // must come first
app.get('/api/jobs', getJobs);
Enter fullscreen mode Exit fullscreen mode

Wrapping up

The frontend and backend are just two separate programs passing text messages to each other. REST is a naming convention that makes those messages predictable. JSON is the text format those messages use. And CORS is the browser being a responsible security guard — protecting users from malicious sites reading data they shouldn't.

Once you see it that way, none of it is magic anymore.


Top comments (0)