Node.jsBackendFull-Stack

Serving a React Frontend from Express

TT
TopicTrick Team
Serving a React Frontend from Express

Serving a React Frontend from Express

Once your API is solid, you need to connect a user interface to it. The simplest path is to serve the React application directly from your Express server — one codebase, one deployment, one domain. No CORS configuration, no separate CDN setup, no split deployment pipeline.

This module covers building the React app, serving it from Express, handling client-side routing, setting up a development proxy, and structuring the project for both development and production.

This is Module 22 of the Node.js Full‑Stack Developer course.


Project Structure

A full-stack Node.js project with a React frontend typically looks like this:

text
my-app/
├── client/                  ← React application (Vite)
│   ├── src/
│   │   ├── main.jsx
│   │   ├── App.jsx
│   │   └── pages/
│   ├── index.html
│   ├── vite.config.js
│   └── package.json
├── server/                  ← Express API
│   ├── app.js
│   ├── server.js
│   ├── features/
│   ├── middleware/
│   └── lib/
├── package.json             ← root (optional monorepo scripts)
└── .env

Or, for a simpler single-package setup:

text
my-app/
├── client/                  ← React source
│   ├── src/
│   └── vite.config.js
├── dist/                    ← React build output (git-ignored)
├── src/                     ← Express source
│   ├── app.js
│   └── server.js
├── package.json
└── .env

Creating the React App with Vite

bash
# From the project root
npm create vite@latest client -- --template react
cd client
npm install

Development: Vite Proxy

In development, Vite serves the React app on port 5173 and Express runs on port 3000. Configure Vite to proxy API requests so the frontend never needs to hardcode the API URL:

js
// client/vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    port: 5173,
    proxy: {
      // Any request to /api/* is forwarded to Express
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
      },
      // Also proxy auth routes (for OAuth callbacks)
      '/auth': {
        target: 'http://localhost:3000',
        changeOrigin: true,
      },
    },
  },
  build: {
    outDir: '../dist',      // build output goes to root /dist
    emptyOutDir: true,
  },
});

Now in development, fetch('/api/posts') in React automatically hits http://localhost:3000/api/posts. No CORS headers needed, no environment variable switching between dev and prod.


Building React for Production

bash
# From the client directory
npm run build

# Or from root with a script
npm run build:client

Vite compiles React into optimised static files in dist/:

text
dist/
├── index.html
├── assets/
│   ├── index-ABC123.js    ← bundled + minified JS
│   └── index-DEF456.css   ← bundled CSS
└── vite.svg

Serving the React Build from Express

js
// src/app.js
import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const app = express();

// ── API routes (must come before static files) ───────────────────────────────
app.use(express.json());
app.use('/api/v1', apiRouter);

// ── Serve React static files ──────────────────────────────────────────────────
const distPath = path.join(__dirname, '..', 'dist');

app.use(
  express.static(distPath, {
    maxAge: '1y',             // cache hashed assets for 1 year
    immutable: true,          // tell browsers the file never changes (safe with hash)
    index: false,             // don't serve index.html automatically — we do it below
  })
);

// ── Catch-all: serve React app for any unmatched GET ─────────────────────────
// This enables client-side routing (React Router)
app.get('*', (req, res) => {
  res.sendFile(path.join(distPath, 'index.html'));
});

export default app;

Why index: false Then a Catch-All?

Setting index: false prevents express.static from serving index.html automatically for /. Instead, the explicit app.get('*') catch-all handles it. This gives you control — you can add response headers, check authentication, or log the request before serving the HTML.


Cache Headers for Static Assets

Vite generates filenames with content hashes (index-ABC123.js). When the code changes, the hash changes and browsers download the new file. This means you can cache hashed assets aggressively:

js
app.use(
  express.static(distPath, {
    setHeaders(res, filePath) {
      if (filePath.endsWith('index.html')) {
        // index.html must not be cached — it is the entry point
        res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
      } else {
        // All other assets have content-hashed filenames — cache forever
        res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
      }
    },
    index: false,
  })
);

npm Scripts

Wire up scripts to build and run in one command:

json
// package.json (root)
{
  "scripts": {
    "dev:server": "nodemon src/server.js",
    "dev:client": "cd client && npm run dev",
    "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
    "build:client": "cd client && npm run build",
    "start": "node src/server.js",
    "build": "npm run build:client"
  }
}
bash
npm install --save-dev concurrently nodemon

Development workflow:

bash
npm run dev       # starts both Express (3000) and Vite (5173) concurrently

Production workflow:

bash
npm run build     # builds React into /dist
npm start         # serves both API and React from Express on port 3000

Environment Variables

Keep secrets on the server, expose only safe values to the client:

text
# .env (server — never expose these to the client)
NODE_ENV=production
PORT=3000
MONGODB_URI=mongodb+srv://...
JWT_ACCESS_SECRET=super-secret
JWT_REFRESH_SECRET=another-secret
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...

# Client-side variables must be prefixed VITE_ (embedded in the JS bundle)
VITE_APP_NAME=MyApp
VITE_SUPPORT_EMAIL=support@example.com

In React:

js
// client/src/config.js
export const APP_NAME     = import.meta.env.VITE_APP_NAME;
export const SUPPORT_EMAIL = import.meta.env.VITE_SUPPORT_EMAIL;
// API URL is always relative — the proxy handles it in dev, same origin in prod
export const API_BASE = '/api/v1';

API Client in React

js
// client/src/lib/api.js
const BASE = '/api/v1';

// In-memory token store (not localStorage — XSS safe)
let _accessToken = null;

export function setAccessToken(token) { _accessToken = token; }
export function getAccessToken()      { return _accessToken; }
export function clearAccessToken()    { _accessToken = null; }

async function request(method, path, body) {
  const headers = { 'Content-Type': 'application/json' };
  if (_accessToken) headers['Authorization'] = `Bearer ${_accessToken}`;

  const res = await fetch(`${BASE}${path}`, {
    method,
    headers,
    credentials: 'include',   // send cookies (for refresh token)
    body: body ? JSON.stringify(body) : undefined,
  });

  // Auto-refresh on 401
  if (res.status === 401 && path !== '/auth/refresh') {
    const refreshed = await refresh();
    if (refreshed) {
      headers['Authorization'] = `Bearer ${_accessToken}`;
      return fetch(`${BASE}${path}`, { method, headers, credentials: 'include', body: body ? JSON.stringify(body) : undefined });
    }
  }

  return res;
}

async function refresh() {
  const res = await fetch(`${BASE}/auth/refresh`, {
    method: 'POST',
    credentials: 'include',
  });
  if (res.ok) {
    const { accessToken } = await res.json();
    setAccessToken(accessToken);
    return true;
  }
  clearAccessToken();
  return false;
}

export const api = {
  get:    (path)        => request('GET',    path),
  post:   (path, body)  => request('POST',   path, body),
  put:    (path, body)  => request('PUT',    path, body),
  patch:  (path, body)  => request('PATCH',  path, body),
  delete: (path)        => request('DELETE', path),
};

React Router with Express Catch-All

React Router handles navigation client-side. The Express catch-all ensures page refreshes work:

jsx
// client/src/App.jsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { HomePage }      from './pages/HomePage';
import { DashboardPage } from './pages/DashboardPage';
import { LoginPage }     from './pages/LoginPage';
import { NotFoundPage }  from './pages/NotFoundPage';
import { useAuth }       from './hooks/useAuth';

function PrivateRoute({ children }) {
  const { user } = useAuth();
  return user ? children : <Navigate to="/login" replace />;
}

export default function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/"          element={<HomePage />} />
        <Route path="/login"     element={<LoginPage />} />
        <Route path="/dashboard" element={
          <PrivateRoute><DashboardPage /></PrivateRoute>
        } />
        <Route path="*"          element={<NotFoundPage />} />
      </Routes>
    </BrowserRouter>
  );
}

When a user refreshes /dashboard, Express serves index.html, React boots, React Router reads the URL, and renders DashboardPage. The user sees their dashboard — no 404.


Health Check Endpoint

Add a health check that both the API and frontend readiness can be verified against:

js
// src/app.js
app.get('/health', (req, res) => {
  res.json({
    status: 'ok',
    uptime: process.uptime(),
    timestamp: new Date().toISOString(),
  });
});

Place it before the API routes so it is never caught by the React catch-all.


Production Checklist

Before deploying a unified Express + React app:

text
✅ Run npm run build to generate /dist
✅ Set NODE_ENV=production
✅ Cache-Control: no-cache on index.html
✅ Cache-Control: immutable on hashed assets
✅ HTTPS enabled (TLS termination at load balancer or reverse proxy)
✅ express.static serves /dist
✅ Catch-all app.get('*') returns index.html
✅ API routes registered before express.static
✅ All secrets in environment variables — not in VITE_ prefix
✅ helmet() enabled for security headers

Node.js Full‑Stack Course — Module 22 of 32

You now have a unified full-stack app serving React from Express. Continue to Module 23 for an introduction to Next.js for Node.js developers.


    Summary

    Serving React from Express is a clean full-stack pattern with minimal moving parts:

    • Configure a Vite proxy in development so /api/* requests forward to Express — no CORS configuration needed
    • Build React with npm run build — Vite produces content-hashed assets in dist/
    • Serve dist/ with express.static, setting index: false so the catch-all controls index.html delivery
    • The catch-all route app.get('*', sendIndex) enables React Router — refreshing any client-side route returns index.html
    • Cache hashed assets for 1 year with immutable; set no-cache on index.html so browsers always fetch the latest entry point
    • Store access tokens in memory; use credentials: 'include' on fetch to send the httpOnly refresh cookie
    • Prefix VITE_ only on variables safe to expose in the browser bundle — never put secrets there
    • Register API routes before express.static or the catch-all will intercept API requests

    Continue to Module 23: Next.js for Node.js Developers →