React · Shipping

React with Node.js API

React in the browser, Express on the server — the classic JavaScript full stack. Getting them to talk smoothly in development and production comes down to three things: URLs, CORS and cookies.

Advanced 7 min Lesson 39 of 42Examples target React 19 with Vite.
Before you start:API Integration
0/42 in React
On this page · 7 min · Advanced

Learning objectives#

  • Call an Express API from React
  • Use Vite’s proxy in development
  • Configure CORS and cookies for production
  • Keep API calls in one module

Explanation#

Development: run Vite on 5173 and Express on 4000. A proxy in vite.config ({ "/api": "http://localhost:4000" }) makes the browser think both are the same origin — no CORS needed, cookies just work.

Production option A: Express also serves the built React files (express.static("dist")) — same origin again. Option B: separate domains (shop.com + api.shop.com) → enable CORS on Express for the exact origin with credentials: true.

Express must parse JSON (app.use(express.json())) and return JSON with proper status codes; React checks res.ok.

For cookie sessions across subdomains, set cookies with Secure, HttpOnly and an appropriate SameSite value, and send requests with credentials: "include".

Keep all endpoints in src/api.js so components call api.getProducts() instead of building URLs.

Syntax#

JavaScript Illustrative
// server.js (Express)
app.use(cors({ origin: 'https://shop.example.com', credentials: true }));
app.use(express.json());
app.get('/api/products', (req, res) => res.json(products));

Code example: React talking to Express#

JSXProductsPage.jsx Runnable
// ── server/index.js (Express, Node.js) ──
// import express from 'express';
// import cors from 'cors';
// const app = express();
// app.use(express.json());
// app.use(cors({ origin: process.env.CLIENT_ORIGIN ?? 'http://localhost:5173', credentials: true }));
// let products = [{ id: 1, name: 'Pen', price: 20 }];
// app.get('/api/products', (req, res) => res.json(products));
// app.post('/api/products', (req, res) => {
//   const { name, price } = req.body;
//   if (!name || !(price > 0)) return res.status(400).json({ error: 'Name and a positive price are required' });
//   const product = { id: Date.now(), name, price };
//   products.push(product);
//   res.status(201).json(product);
// });
// app.listen(4000);


// ── client/src/ProductsPage.jsx (React) ──
import { useEffect, useState } from 'react';


const API = import.meta.env?.VITE_API_URL ?? '/api';     // '/api' uses the Vite proxy in dev


const api = {
  async list() {
    const res = await fetch(`${API}/products`, { credentials: 'include' });
    if (!res.ok) throw new Error('Could not load products');
    return res.json();
  },
  async create(product) {
    const res = await fetch(`${API}/products`, {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(product),
    });
    const body = await res.json();
    if (!res.ok) throw new Error(body.error ?? 'Could not save');
    return body;
  },
};


export default function ProductsPage() {
  const [products, setProducts] = useState([]);
  const [error, setError] = useState('');


  useEffect(() => { api.list().then(setProducts).catch(e => setError(e.message)); }, []);


  async function handleSubmit(e) {
    e.preventDefault();
    const formElement = e.currentTarget;          // keep it: currentTarget is null after await
    const form = new FormData(formElement);
    try {
      const created = await api.create({ name: form.get('name'), price: Number(form.get('price')) });
      setProducts(p => [...p, created]);
      setError('');
      formElement.reset();
    } catch (err) {
      setError(err.message);
    }
  }


  return (
    <>
      <ul>{products.map(p => <li key={p.id}>{p.name} — ₹{p.price}</li>)}</ul>
      <form onSubmit={handleSubmit}>
        <input name="name" aria-label="Name" />
        <input name="price" type="number" aria-label="Price" />
        <button>Add</button>
      </form>
      {error && <p role="alert">{error}</p>}
    </>
  );
}
Expected output
With Express on :4000 and Vite on :5173 (proxying /api), the page lists “Pen — ₹20”. Adding “Bag” with price 650 POSTs JSON, Express answers 201 and the list grows. Submitting without a price shows Express’s message “Name and a positive price are required”.

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. Express parses JSON, enables CORS for the client origin and validates input.
  2. In development, React calls /api/... and Vite forwards it to Express.
  3. In production, VITE_API_URL points to the real API (or Express serves the React build).
  4. The api object hides URLs, headers, credentials and error parsing.
  5. Server error messages from 400 responses are shown to the user.
  6. The form element is saved before await, because React clears e.currentTarget once the handler has yielded.

Real-world use case#

A MERN-style (MySQL instead of MongoDB) shop runs Express on a VPS behind Nginx, which also serves the React build — one domain, no CORS, secure session cookies.

Common mistakes#

CORS errors in development

Calling http://localhost:4000 directly from 5173 needs CORS. Use the Vite proxy instead.

origin: "*" with credentials

Browsers refuse credentialed requests to wildcard origins. Specify the exact origin.

Missing express.json()

req.body is undefined and every POST fails validation.

Best practices#

  • Proxy in dev, same origin or strict CORS in prod
  • Validate on the server and return clear JSON errors
  • One API module in the client
  • Configure URLs with environment variables

Remember this#

Dev: Vite proxy /api → Express.

Prod: same origin, or CORS with the exact origin + credentials.

express.json() + res.status(...).json(...).

Practice exercise#

Your task: Write the vite.config.js server.proxy setting that forwards /api to http://localhost:4000 and explain why it avoids CORS.

Need a hint?
server: { proxy: { "/api": "http://localhost:4000" } }.
Show solution
JavaScript Illustrative
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';


export default defineConfig({
  plugins: [react()],
  server: {
    proxy: { '/api': 'http://localhost:4000' },
  },
});
// The browser only talks to localhost:5173. Vite forwards /api requests to Express
// on the server side, so from the browser's point of view everything is same-origin.

Mini quiz#

React with Node.js API quizMultiple choice Question 1 of 3

Why does the Vite proxy avoid CORS in development?

Score so far: 0/0

Want more? Practise all React MCQs · React interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.