π Connecting React Frontend to Express Backend
A React app in the browser and an Express server on Node.js are two separate programs that speak to each other only over HTTP. This lesson shows you how to lay out a MERN project, expose a JSON API from Express, and call it from React β clearing the CORS and proxy hurdles that trip up nearly everyone the first time.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain how a React client and an Express server communicate as two distinct processes over HTTP
- Structure a MERN project as a monorepo with separate
client/andserver/folders - Build a minimal Express JSON API with
express.json()and route handlers - Diagnose and fix CORS errors and configure the Vite dev proxy
- Create a reusable Axios service layer and consume it from a React component
- Serve the built React app from Express for a single-server production deployment
Estimated Time: 45β60 minutes β’ Difficulty: Intermediate
Hands-on: Build a "ping" endpoint in Express and call it from a React component, then wire up the Vite proxy.
In This Lesson
Two Programs, One Application
It's easy to think of a full stack app as a single thing, but the "front end" and "back end" are literally two separate programs running in two different places. Your React app runs in the user's browser as downloaded JavaScript. Your Express server runs on Node.js on a machine somewhere. Neither can call the other's functions directly β they can only exchange HTTP requests and responses, usually carrying JSON.
π‘ A useful analogy: The React app and the Express server are like two people in different offices who can only communicate by passing notes (HTTP requests) through a mail slot. Everything the frontend wants β data, a login, a saved record β must be phrased as a request, sent across, and answered. There is no shouting across the room.
The MERN stack is one popular way to fill in the pieces on both sides, using JavaScript everywhere:
- MongoDB β a document database that stores JSON-like records
- Express β a minimal web framework for Node.js that defines your API routes
- React β the library that builds the user interface in the browser
- Node.js β the runtime that executes your server-side JavaScript
Project Structure
You have two sensible ways to organize the two halves. Both are valid; the choice is about team workflow, not correctness.
π Two layouts
Monorepo: both apps live in one repository under client/ and server/. Easiest to run and deploy together β great for learning and small teams.
Separate repos: the frontend and backend are independent repositories deployed independently. Common at larger companies where different teams own each side.
We'll use a monorepo. Think of it like one office building with two departments, rather than two branch offices across town. Here's the shape we're aiming for:
my-mern-app/
βββ package.json # root: scripts to run both halves
βββ client/ # React app (created by Vite)
β βββ package.json
β βββ vite.config.js # dev proxy lives here
β βββ index.html
β βββ src/
β βββ main.jsx
β βββ App.jsx
β βββ services/ # Axios API layer
β βββ components/
βββ server/ # Express app
βββ package.json
βββ server.js
βββ routes/
βββ controllers/
βββ models/
Scaffold it from scratch. Modern React projects use Vite, not the deprecated Create React App:
# Create the project root
mkdir my-mern-app && cd my-mern-app
npm init -y
# Frontend: scaffold a React app with Vite
npm create vite@latest client -- --template react
cd client && npm install && cd ..
# Backend: an Express app
mkdir server && cd server
npm init -y
npm install express cors mongoose dotenv
npm install --save-dev nodemon
cd ..
# Root helper to run both at once
npm install --save-dev concurrently
Wire up convenience scripts in the root package.json so one command boots both servers:
{
"name": "my-mern-app",
"scripts": {
"server": "nodemon server/server.js",
"client": "npm run dev --prefix client",
"dev": "concurrently \"npm run server\" \"npm run client\""
},
"devDependencies": {
"concurrently": "^9.0.0"
}
}
Now npm run dev starts Express (typically on port 5000) and the Vite dev server (port 5173) side by side.
A Minimal Express JSON API
The backend's whole job here is to receive HTTP requests and answer with JSON. Start with the smallest server that does something useful β a health-check route.
// server/server.js
import express from 'express';
import cors from 'cors';
import 'dotenv/config';
const app = express();
const PORT = process.env.PORT || 5000;
// Middleware: allow cross-origin calls and parse JSON bodies
app.use(cors());
app.use(express.json());
// A simple health-check endpoint
app.get('/api/ping', (req, res) => {
res.json({ message: 'pong', time: new Date().toISOString() });
});
// A tiny in-memory example resource
const notes = [{ id: 1, text: 'Learn how React talks to Express' }];
app.get('/api/notes', (req, res) => {
res.json(notes);
});
app.post('/api/notes', (req, res) => {
const { text } = req.body; // works because of express.json()
if (!text) {
return res.status(400).json({ message: 'text is required' });
}
const note = { id: notes.length + 1, text };
notes.push(note);
res.status(201).json(note); // 201 Created
});
app.listen(PORT, () => {
console.log(`API running on http://localhost:${PORT}`);
});
β οΈ Two middleware lines that matter
app.use(express.json()) parses incoming JSON request bodies into req.body. Forget it and req.body is undefined on every POST. app.use(cors()) allows the browser to accept responses from a different origin β the subject of the next section.
Verify the server works before touching React. A quick curl proves the API in isolation:
curl http://localhost:5000/api/ping
Response
{ "message": "pong", "time": "2026-01-15T10:22:01.004Z" }
The CORS Problem
Here is the error nearly every beginner hits. Your React app served from http://localhost:5173 tries to fetch from http://localhost:5000, and the browser refuses:
Console
Access to fetch at 'http://localhost:5000/api/notes' from origin
'http://localhost:5173' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.
CORS (Cross-Origin Resource Sharing) is a browser security rule. Because the two servers have different origins (the port differs, so the origin differs), the browser blocks the response unless the server explicitly opts in by sending an Access-Control-Allow-Origin header.
π‘ Analogy: CORS is a bouncer at a club who checks whether visitors from another address are on the guest list before letting their response through. The cors middleware is how the server adds names to that list.
For development, app.use(cors()) allows every origin. In production, lock it down to your real frontend domain:
// Development: allow all origins
app.use(cors());
// Production: allow only your deployed frontend
app.use(cors({
origin: process.env.CLIENT_URL, // e.g. https://myapp.netlify.app
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true // needed if you use cookies
}));
The Vite Dev Proxy
There's an even cleaner way to avoid CORS entirely during development: have Vite forward API calls to Express for you. From the browser's point of view, every request goes to localhost:5173 β the same origin β so there's no cross-origin problem at all.
Configure a proxy in the client's vite.config.js:
// client/vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
// Any request starting with /api is forwarded to Express
'/api': 'http://localhost:5000'
}
}
});
With this in place, your React code can call the relative path /api/notes and Vite transparently forwards it to http://localhost:5000/api/notes. The bonus: the same relative path works in production too, because you'll deploy them under one origin.
π‘ Proxy vs. CORS β which do I use?
Use the Vite proxy for local development so the browser sees one origin. Keep the CORS middleware configured for production (and for any tool like a mobile app or Postman that calls the API cross-origin). They solve overlapping problems from opposite ends; a real project usually has both.
Calling the API from React
Don't scatter fetch or axios calls throughout your components. Centralize them in a small service layer so every component talks to the API the same way and you can change the base URL in one place.
The shared Axios instance
// client/src/services/api.js
import axios from 'axios';
// Relative baseURL β the Vite proxy (dev) or same origin (prod) handles routing
const api = axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' }
});
// Attach a saved auth token to every outgoing request
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default api;
A resource-specific service
// client/src/services/noteService.js
import api from './api';
export const noteService = {
getAll: () => api.get('/notes').then((res) => res.data),
create: (text) => api.post('/notes', { text }).then((res) => res.data)
};
Consuming it in a component
A React component fetches on mount and handles the three states every network call has: loading, error, and success.
// client/src/components/Notes.jsx
import { useState, useEffect } from 'react';
import { noteService } from '../services/noteService';
export default function Notes() {
const [notes, setNotes] = useState([]);
const [text, setText] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
noteService
.getAll()
.then(setNotes)
.catch((err) => setError(err.response?.data?.message || 'Failed to load notes'))
.finally(() => setLoading(false));
}, []);
async function handleAdd(e) {
e.preventDefault();
if (!text.trim()) return;
try {
const created = await noteService.create(text);
setNotes((prev) => [...prev, created]); // optimistic-ish update
setText('');
} catch (err) {
setError(err.response?.data?.message || 'Failed to add note');
}
}
if (loading) return <p>Loading notesβ¦</p>;
if (error) return <p role="alert">{error}</p>;
return (
<section>
<h2>Notes</h2>
<ul>
{notes.map((n) => (
<li key={n.id}>{n.text}</li>
))}
</ul>
<form onSubmit={handleAdd}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="New note"
/>
<button type="submit">Add</button>
</form>
</section>
);
}
β Why a service layer pays off
Components stay focused on rendering. The base URL, auth headers, and error shape live in one file. When you later swap the proxy for a real domain, or add token refresh, you touch api.js β not fifty components.
Hands-on: Build a Ping
ποΈ Connect the two halves end to end
Objective: Prove the full round trip β a React button that fetches a message from Express and shows it.
Instructions:
- In
server/server.js, confirm theGET /api/pingroute from Section 3 returns{ message: 'pong' }. - Add the proxy block to
client/vite.config.jsso/apiforwards to port 5000. - Create a
PingButton.jsxcomponent that calls/api/pingwith the sharedapiinstance and displays the returned message. - Handle the loading and error states so the UI never silently hangs.
- Run
npm run devfrom the root and click the button.
π‘ Hint
Import the api instance from ../services/api and call api.get('/ping'). Store the result in state with useState. Wrap the call in try/catch and set an error message on failure. You do not need the full origin β the proxy makes /api/ping enough.
β Sample solution
// client/src/components/PingButton.jsx
import { useState } from 'react';
import api from '../services/api';
export default function PingButton() {
const [message, setMessage] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
async function ping() {
setLoading(true);
setError('');
try {
const res = await api.get('/ping');
setMessage(res.data.message);
} catch (err) {
setError('Could not reach the server');
} finally {
setLoading(false);
}
}
return (
<div>
<button onClick={ping} disabled={loading}>
{loading ? 'Pingingβ¦' : 'Ping the server'}
</button>
{message && <p>Server says: <strong>{message}</strong></p>}
{error && <p role="alert">{error}</p>}
</div>
);
}
Click it and you should see "Server says: pong" β the browser, the proxy, and Express all working together.
Deployment & Best Practices
Two deployment shapes
Separate deployment: host the built React app on a static host (Netlify, Vercel) and the Express API on a Node host (Render, Railway, Fly.io). You must configure CORS to allow the frontend's domain.
Single-server deployment: Express serves both the API and the built React files. One origin, no CORS needed in production. Add this to the bottom of your server, after the API routes:
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Serve the built React app in production
if (process.env.NODE_ENV === 'production') {
const clientDist = path.join(__dirname, '../client/dist');
app.use(express.static(clientDist));
// Any non-API route returns index.html so client-side routing works
app.get('*', (req, res) => {
res.sendFile(path.join(clientDist, 'index.html'));
});
}
Build the client with npm run build --prefix client; Vite outputs to client/dist.
β οΈ Do & Don't
Do keep secrets in .env and add it to .gitignore. Do use relative /api paths so dev and prod behave the same. Do handle loading and error states on every request.
Don't hard-code http://localhost:5000 in components. Don't ship cors() wide-open to production. Don't put your Vite proxy config on the server β it belongs to the dev client only.
π― Quick Quiz
Question 1: Your React app on port 5173 gets "blocked by CORS policy" when calling Express on port 5000. What is the direct cause?
Question 2: What does app.use(express.json()) do?
Question 3: Why put the Axios base URL and auth header in a single api.js instead of each component?
Summary & Quiz
π Key Takeaways
- The React client and Express server are separate processes that communicate only over HTTP with JSON.
- A monorepo with
client/andserver/plusconcurrentlylets you run both with one command. - CORS blocks cross-origin responses; fix it with the
corsmiddleware (locked down in production). - The Vite dev proxy forwards
/apito Express so the browser sees one origin during development. - A centralized Axios service layer keeps components clean and configuration in one place.
- Single-server deployment lets Express serve the built React app, eliminating CORS in production.
π Further Reading
- Vite β Server Proxy configuration
- Express β Using middleware
- MDN β Cross-Origin Resource Sharing (CORS)
- Axios documentation
π What's Next?
You now have a working connection. Next we go deeper on the client side of that connection β API Requests with Axios β covering instances, interceptors, cancellation, and reusable data-fetching hooks.
π Nicely done!
Your frontend and backend are talking. Everything else in MERN is built on this handshake.