Build for Production and Serve the React App from Express

MERN Stack Full Course: MongoDB, Express, React and Node.js · Deploy and Launch · 6 min read · Updated

One server for everything

  1. The build turns your React source into a few hashed static files in client/dist.
  2. Express serves them. Hashed files in /assets are cached for a year, and index.html is never cached.
  3. Requests that start with /api go to the API, and an unknown API path is a JSON 404.
  4. Any other GET gets index.html, so React Router can draw deep links after a reload.
  5. One process and one origin: no CORS needed, and only one thing to deploy.

In development you ran two servers: Vite for the screens and Express for the API. In production the simplest setup runs one. npm run build turns your React source into a small set of static files, and Express serves those files and the API from the same address. That removes CORS from the picture, needs only one process to manage, and means the token and the pages share an origin.

What the build does

vite build bundles your JSX and CSS into plain JavaScript and CSS files in client/dist, minified, with a hash in each file name (assets/index-BBRz7k9l.js). The hash changes whenever the content changes. That detail drives the caching rules below.

Serving the build

// Lesson 38: in production, the same Express app also serves the built React app.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import mongoose from 'mongoose';
import { logger } from './middleware/logger.js';
import { authLimiter, rejectOperators } from './middleware/security.js';
import { notFound, errorHandler } from './middleware/error.js';
import authRouter from './routes/auth.js';
import jobsRouter from './routes/jobs.js';

export const app = express();

if (process.env.TRUST_PROXY) app.set('trust proxy', Number(process.env.TRUST_PROXY) || process.env.TRUST_PROXY);

app.use(helmet());
app.use(compression());
if (process.env.NODE_ENV !== 'test') app.use(logger);
app.use(cors({ origin: (process.env.CLIENT_ORIGIN || 'http://localhost:5173').split(',') }));
app.use(express.json({ limit: '100kb' }));
app.use(rejectOperators);

app.get('/api/health', (req, res) => {
  const db = mongoose.connection.readyState === 1;
  res.status(db ? 200 : 503).json({ ok: db, db: db ? 'connected' : 'not connected', uptimeSeconds: Math.round(process.uptime()) });
});
app.use('/api/auth', authLimiter, authRouter);
app.use('/api/jobs', jobsRouter);
app.use('/api', notFound); // an unknown /api path is a JSON 404, never the React page

// ---- the React build (client/dist), if it exists ----
const dist = process.env.CLIENT_DIST || path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'client', 'dist');
if (fs.existsSync(path.join(dist, 'index.html'))) {
  // Files in /assets have a hash in their name, so they can be cached for a year. index.html must never be cached,
  // or visitors would keep an old page that points at files you have deleted.
  app.use(express.static(dist, {
    index: false,
    setHeaders: (res, file) => res.setHeader('Cache-Control', file.includes(`${path.sep}assets${path.sep}`) ? 'public, max-age=31536000, immutable' : 'no-cache'),
  }));
  // React Router handles paths like /jobs/123 in the browser, so any other GET gets index.html.
  app.get(/^\/(?!api\/).*/, (req, res) => {
    res.setHeader('Cache-Control', 'no-cache');
    res.sendFile(path.join(dist, 'index.html'));
  });
}

app.use(notFound);
app.use(errorHandler);
  • The folder. client/dist is found relative to this file, or from CLIENT_DIST. If it does not exist (for example in tests or in development), nothing is served and the API still works.
  • Caching. Files in /assets have the hash in their name, so they can never change under the same name. They get Cache-Control: public, max-age=31536000, immutable, a year. index.html must never be cached: it points at the current hashed files, and an old cached copy would point at files you have deleted. It gets no-cache.
  • The fallback. React Router draws pages like /jobs/123 in the browser, but the server has no such file. app.get(/^\/(?!api\/).*/, ...) answers any GET that does not start with /api/ with index.html, so reloading or sharing a deep link works.
  • API 404s. app.use('/api', notFound) comes *before* the fallback. An unknown API path must be a JSON 404, and not a copy of the home page, which would confuse scripts and hide bugs.

Security headers and the build

helmet() sets a Content Security Policy that only allows scripts from your own site. The Vite build loads its code from files, not inline scripts, so it works under that policy. If you later add something like an analytics script or fonts from another site, you will have to allow them in the policy on purpose. That is the policy doing its job.

Run it like production

{
  "name": "mern-job-tracker",
  "private": true,
  "version": "1.0.0",
  "description": "Job Tracker: a MongoDB, Express, React and Node.js app",
  "scripts": {
    "setup": "npm install --prefix server && npm install --prefix client",
    "server": "npm run dev --prefix server",
    "client": "npm run dev --prefix client",
    "dev": "concurrently -n api,web -c blue,green \"npm:server\" \"npm:client\"",
    "build": "npm run build --prefix client",
    "start": "npm start --prefix server"
  },
  "devDependencies": {
    "concurrently": "^9.1.0"
  }
}

From the project root: npm run build creates client/dist, and npm start starts the server. With NODE_ENV=production and a real JWT_SECRET set in server/.env, open http://localhost:5000 and use the whole app from that single port. This is exactly what the server will do after deployment, so it is the best test before you deploy.

Your task

  1. Run npm run build at the project root. Look at the client/dist/assets folder and the file sizes.
  2. Start only the server (npm start) and open http://localhost:5000. Sign in and use the app.
  3. Reload on a deep link such as /jobs/<an id>. It should work.
  4. Open /api/nothing and /nothing and compare the answers.

How to check it worked

  • The app works from port 5000 with no Vite running.
  • Deep links survive a reload.
  • /api/nothing is JSON 404, and /nothing shows the React 404 page.

Summary

  • vite build creates hashed static files; Express serves them with year-long caching for /assets and none for index.html.
  • Send index.html for every non-API GET so client-side routes work.
  • Keep the order: API, API 404, static, fallback, error handler.

Download the solution (mern-lesson-38.zip)

Frequently asked questions

Why is index.html not cached but assets are?

Asset file names contain a hash of their content, so a new version has a new name and can be cached for a year. index.html has a fixed name and points at the current assets, so it must be fetched fresh or visitors could get stale pages.

Do I need nginx if Express serves the files?

Not strictly. nginx in front is still useful for HTTPS, large uploads, compression and serving many sites on one server, which is what lesson 39 sets up.

Why serve the API and the React app from one origin?

The browser then sees a single origin, so no CORS settings are needed, and there is one thing to deploy and monitor. Large teams sometimes split them for scaling reasons.