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/distis found relative to this file, or fromCLIENT_DIST. If it does not exist (for example in tests or in development), nothing is served and the API still works. - Caching. Files in
/assetshave the hash in their name, so they can never change under the same name. They getCache-Control: public, max-age=31536000, immutable, a year.index.htmlmust never be cached: it points at the current hashed files, and an old cached copy would point at files you have deleted. It getsno-cache. - The fallback. React Router draws pages like
/jobs/123in the browser, but the server has no such file.app.get(/^\/(?!api\/).*/, ...)answers any GET that does not start with/api/withindex.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 JSON404, 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
- Run
npm run buildat the project root. Look at theclient/dist/assetsfolder and the file sizes. - Start only the server (
npm start) and openhttp://localhost:5000. Sign in and use the app. - Reload on a deep link such as
/jobs/<an id>. It should work. - Open
/api/nothingand/nothingand compare the answers.
How to check it worked
- The app works from port 5000 with no Vite running.
- Deep links survive a reload.
/api/nothingis JSON 404, and/nothingshows the React 404 page.
Summary
vite buildcreates hashed static files; Express serves them with year-long caching for/assetsand none forindex.html.- Send
index.htmlfor every non-API GET so client-side routes work. - Keep the order: API, API 404, static, fallback, error handler.