5+ years software engineer
5+ years software engineer
5+ years software engineer
5+ years software engineer
Your React front end and your Node.js back end are two separate programs. They don't share memory, variables, or function calls. They communicate the only way two independent programs on a network can: by sending HTTP requests and responses over the wire. Once that clicks, "connecting" React to Node stops being mysterious — you're just making the browser call a URL and rendering what comes back. I've wired this exact setup on every client project I've shipped, and the confusion almost always comes from expecting the two to be more connected than they are.
This is a deep dive under my full-stack app guide. Here I'm focused on one thing: getting the browser and the server to talk cleanly, in development and in production.
The mental model is a conversation:
text1 React (browser, :5173) Node/Express (server, :4000) 2 │ │ 3 │ GET /api/tasks │ 4 │ ─────────────────────────────────────▶│ 5 │ │ query DB, build JSON 6 │ 200 OK [{...}, {...}] │ 7 │ ◀─────────────────────────────────────│ 8 │ render list │
The client (React) asks. The server (Node) answers. Neither knows anything about the other's internals — the server doesn't know React exists, and React doesn't know whether the server is Node, Python, or Go. All they share is the contract: URLs, methods, and JSON. That decoupling is a feature. You can rewrite either side independently as long as the HTTP contract holds.
Here's a minimal but complete API. Two pieces of middleware do the heavy lifting:
js1// backend/server.js 2import express from 'express' 3import cors from 'cors' 4 5const app = express() 6 7app.use(cors({ origin: 'http://localhost:5173', credentials: true })) 8app.use(express.json()) 9 10app.get('/api/tasks', (req, res) => { 11 res.json([{ id: 1, title: 'Ship it' }]) 12}) 13 14app.post('/api/tasks', (req, res) => { 15 const { title } = req.body // populated by express.json() 16 res.status(201).json({ id: 2, title }) 17}) 18 19app.listen(4000, () => console.log('API on http://localhost:4000'))
express.json() parses incoming JSON request bodies into req.body — without it, req.body is undefined and every POST looks empty. cors(...) sets the response headers that tell the browser your API is willing to talk to the React origin. More on that next, because it's the single most common thing that trips people up.
React calls the API with fetch (built in) or axios (a small library some prefer). Both do the same job. Here's fetch:
jsx1import { useEffect, useState } from 'react' 2 3export function App() { 4 const [tasks, setTasks] = useState([]) 5 6 useEffect(() => { 7 fetch('/api/tasks', { credentials: 'include' }) 8 .then((res) => { 9 if (!res.ok) throw new Error(`HTTP ${res.status}`) 10 return res.json() 11 }) 12 .then(setTasks) 13 .catch(console.error) 14 }, []) 15 16 async function addTask(title) { 17 const res = await fetch('/api/tasks', { 18 method: 'POST', 19 headers: { 'Content-Type': 'application/json' }, 20 credentials: 'include', 21 body: JSON.stringify({ title }), 22 }) 23 const task = await res.json() 24 setTasks((prev) => [...prev, task]) 25 } 26 27 return <button onClick={() => addTask('New')}>Add ({tasks.length})</button> 28}
The same with axios, if you'd rather:
js1import axios from 'axios' 2const api = axios.create({ baseURL: '/api', withCredentials: true }) 3 4const { data } = await api.get('/tasks') 5await api.post('/tasks', { title: 'New' })
Axios sets Content-Type and parses JSON for you, and throws on non-2xx responses automatically — with fetch you check res.ok yourself. For most apps it's preference. I use fetch for small things and axios when I want interceptors (e.g. attaching an auth token to every request).
CORS — Cross-Origin Resource Sharing — is the thing everyone hits and few understand. Browsers enforce a same-origin policy: by default, JavaScript on localhost:5173 is not allowed to read responses from localhost:4000, because a different port means a different origin. This is a security boundary that stops a malicious site from quietly reading responses from other sites you're logged into.
Your API opts specific origins back in by sending Access-Control-Allow-Origin headers — which is exactly what the cors() middleware does. Two things to know:
curl and Postman ignore it entirely, which is why an endpoint can work in Postman but fail in the browser.Rather than fight CORS locally, I point the dev server at the API. Vite forwards any request starting with /api to localhost:4000, so from the browser's perspective everything is same-origin:
js1// frontend/vite.config.js 2export default { 3 server: { 4 proxy: { 5 '/api': { target: 'http://localhost:4000', changeOrigin: true }, 6 }, 7 }, 8}
Create React App uses a one-line "proxy": "http://localhost:4000" in package.json instead. Either way, your React code calls /api/tasks with no host — which is also exactly how it'll behave in production if you serve both from the same origin. Relative URLs everywhere means nothing to change at deploy time.
I keep the two apps in sibling folders. Clear boundary, independent dependencies:
text1my-app/ 2├── backend/ 3│ ├── package.json 4│ ├── server.js 5│ └── routes/ 6│ └── tasks.js 7└── frontend/ 8 ├── package.json 9 ├── vite.config.js 10 └── src/ 11 ├── App.jsx 12 └── main.jsx
Each folder has its own package.json and node_modules because they're genuinely separate apps with separate dependencies — Express in one, React in the other. Some teams use a monorepo tool to manage both, but two plain folders is perfectly fine and easier to reason about.
In development you run two processes. Simplest is two terminals:
bash1# terminal 1 2cd backend && node server.js 3 4# terminal 2 5cd frontend && npm run dev
To run them with one command, concurrently is the standard tool:
bash1npm install -D concurrently
json1{ 2 "scripts": { 3 "dev": "concurrently \"npm --prefix backend start\" \"npm --prefix frontend run dev\"" 4 } 5}
In production, React isn't served by the Vite dev server — you build it to static files (npm run build → a dist/ folder of HTML/CSS/JS). Then you have a choice:
Option 1 — serve the React build from Express (single origin). Point Express at the built files. One server, one domain, no CORS at all:
js1import path from 'path' 2app.use(express.static(path.join(process.cwd(), '../frontend/dist'))) 3// SPA fallback: send index.html for any non-API route 4app.get(/^(?!\/api).*/, (req, res) => 5 res.sendFile(path.join(process.cwd(), '../frontend/dist/index.html')) 6)
Option 2 — separate hosts. Static files on a CDN or static host, the API on its own server, on different domains (app.example.com and api.example.com). More scalable, but now CORS is real — configure cors() with your production frontend origin.
I lean toward Option 2 for anything that needs to scale, because a CDN serves static assets far better than Node can, and the two tiers scale independently. Which one fits your app — and the deploy mechanics for both — is the subject of how to deploy a full-stack app.
You can now make React and Node talk in dev and prod. Two natural next steps: build out real data operations on this connection with how to build a CRUD application, and add sign-in so those requests are scoped to a user with how to authenticate users in a web application — where you'll see why credentials: 'include' in the fetch calls above matters. For the whole picture, head back to the full-stack app guide.
The exact path I follow to ship a full-stack app end to end — data model, Node/Express API, database, React frontend, auth, security, and deploy — with links to the deep dives for each step.
Authentication has four parts: verifying identity, managing sessions, authorizing actions, and expiring access safely. I cover password hashing, sessions vs JWT, HttpOnly cookies, OAuth, passkeys, and the architecture I actually recommend.
A layered, defense-in-depth checklist for securing an API — HTTPS, auth, RBAC and object-level checks against BOLA/IDOR, rate limiting, input validation, safe errors, logging, secret management, and the OWASP API Security Top 10. Plus the myths that get people breached.