Deploying a React Application
Learn how to build a React application for production, host it as a static site behind a CDN, configure SPA fallback routing and environment variables, and understand the CI/CD, caching, HTTPS, and rollback concerns that separate a working demo from a production deployment — and how a full-stack React framework changes these tradeoffs.
Everything you've built so far has run in a development server that recompiles on every keystroke and shows you unminified stack traces. None of that exists in production. Deployment is the process of turning your source code into a small set of optimized static files (or a running server process), putting them somewhere the public internet can reach quickly and securely, and setting up a repeatable pipeline so that shipping the next change is a routine, low-risk event rather than a manual, error-prone ritual.
The production build
A production build is a distinct step from development. Running npm run build (Vite, Create React App, or any modern toolchain) triggers a bundler that transforms your JSX/TypeScript source into plain JavaScript, tree-shakes unused code, minifies and compresses the output, splits it into content-hashed chunks for caching, and inlines or extracts CSS. The result lands in a dist/ or build/ folder containing an index.html, one or more hashed JS bundles, hashed CSS files, and any static assets.
Content hashing is the detail that makes caching safe: a file named main.a1b2c3d4.js changes its filename whenever its contents change. This lets you cache these files aggressively forever, because a stale cached copy simply has a different name than the new one and is never served in its place.
React's production build also strips development-only warnings, disables extra validation checks that exist purely to help you during development, and typically shrinks bundle size substantially compared to the development bundle — production React is measurably faster and smaller than the copy you've been developing against.
• npm run build — produces optimized, minified, content-hashed static output • Development builds include extra warnings and checks; production builds strip them for speed • Content-hashed filenames (main.[hash].js) enable safe, aggressive browser caching • Source maps can optionally be generated alongside the build for production debugging
Static hosting and CDNs
A React SPA (single-page application) build is, after the build step, just static files: HTML, JS, CSS, images. This means it can be hosted anywhere that can serve static files — no application server, no runtime, no database connection required for the frontend itself. Static hosts (Netlify, Vercel, Cloudflare Pages, GitHub Pages, S3 + CloudFront, and similar) exist specifically to serve these files cheaply and reliably.
A CDN (Content Delivery Network) is a globally distributed network of edge servers that cache and serve your static files from a location physically close to each visitor, rather than every request traveling back to one origin server. This dramatically reduces latency, especially for users far from your origin, and absorbs traffic spikes that would overwhelm a single server.
Because your build output is content-hashed and immutable, it is an ideal fit for CDN caching: the CDN can cache each file essentially forever and still always serve the correct version, because a new deployment produces new filenames rather than overwriting old ones in place.
SPA fallback routing
A React Router-driven SPA has client-side routes like /dashboard or /users/42 that exist only in JavaScript — there is no actual file at dist/dashboard/index.html. If a visitor requests that URL directly (typing it in, refreshing the page, or following a bookmark), a naive static file server returns a 404, because no such file exists on disk.
The fix is SPA fallback configuration: tell the host to serve index.html for any path that doesn't match a real file, with a 200 status (not a redirect). The browser loads index.html, React and React Router boot up, read the URL from window.location, and render the correct view client-side. Every static host has its own syntax for this — Netlify uses a _redirects file or netlify.toml, Vercel uses vercel.json rewrites, Nginx uses try_files.
• Without SPA fallback: refreshing /dashboard returns a 404 from the file server • With SPA fallback: unmatched paths serve index.html with status 200, and React Router takes over • The fallback must NOT apply to real static assets (JS, CSS, images) or you'll serve HTML for a .js request
Environment configuration
Applications typically need different configuration per environment: a development API points at localhost, staging points at a staging backend, and production points at the real API. Build tools expose this through environment variables read at build time — Vite uses import.meta.env.VITE_API_URL, Create React App uses process.env.REACT_APP_API_URL — and these must be prefixed as the tool requires to be exposed to client code.
Critically, these values are baked into the JavaScript bundle at build time, not read at runtime from the server's environment. This means switching environments requires a separate build per environment (or a build that reads truly-runtime config from a small JSON file fetched at startup, if you need one artifact to work across environments). It also means you must never put secrets (API keys with write access, private tokens) in these variables — anything in a client bundle is visible to any visitor who opens dev tools.
HTTPS and cache headers
Every production site must be served over HTTPS. Modern browsers restrict powerful APIs (geolocation, service workers, clipboard access) to secure contexts, and HTTPS also protects against man-in-the-middle tampering and eavesdropping. Nearly all static hosts provision free, auto-renewing TLS certificates automatically — there is rarely a reason to serve plain HTTP in production today.
Cache headers tell browsers and CDNs how long they may keep a file before re-checking with the origin. The correct pattern for a hashed SPA build is: cache index.html itself with Cache-Control: no-cache (or a very short max-age) so visitors always get the latest reference to your bundles, while caching the hashed JS/CSS files with Cache-Control: public, max-age=31536000, immutable — a full year, because their filename changes whenever their content does, so long caching is always safe.
Getting this backwards — caching index.html for a year — is a classic deployment bug: visitors get stuck on an old version of the app because their browser never re-fetches the HTML that points at the new bundle filenames.
Source maps
Minified production JavaScript is unreadable — variable names are shortened, whitespace is stripped, code is rearranged. A source map is a separate file that maps positions in the minified output back to the original source, so error-tracking tools (Sentry, browser dev tools with the map loaded) can show you a real stack trace with your actual file names, line numbers, and variable names instead of t.default(e,n,r) at main.a1b2.js:1:48213.
Most build tools generate source maps in production if you opt in, but you generally do NOT want to serve them publicly alongside your JS — that would let anyone reconstruct your original source. The common pattern is to generate them, upload them privately to an error-tracking service, and exclude .map files from the public deployment (or serve them but not link them from the JS, depending on your threat model).
CI/CD and rollback
Continuous Integration / Continuous Deployment automates the path from a merged commit to a live deployment: a pipeline (GitHub Actions, GitLab CI, or the host's built-in Git integration) runs on every push, executes npm install, runs tests and linting, runs npm run build, and — if everything passes — deploys the build output automatically. This removes manual, error-prone deploy steps and ensures nothing untested reaches production.
Rollback is the ability to instantly revert to a previously known-good deployment when a release turns out to be broken. Because each deployed build is an immutable, content-hashed artifact, most modern static hosts keep every past deployment and let you re-point production traffic at a previous one in seconds — far faster than reverting a commit and waiting for a fresh build. Treat 'can we roll back in under a minute' as a hard requirement for any deployment setup, not a nice-to-have.
Static SPA vs. SSR vs. full-stack React frameworks
A plain static SPA (what this lesson mostly describes) ships an essentially empty index.html plus a JS bundle; the browser downloads the JS, executes React, and only then renders content. This is simple to host (any static file server works) and cheap to scale, but has a slower time-to-first-content, and pages are empty until JavaScript runs — a real concern for SEO and for users on slow connections or older devices.
Server-Side Rendering (SSR) runs your React components on a server for each request, producing fully-formed HTML that the browser can paint immediately, then 'hydrates' that HTML with React on the client so it becomes interactive. This gives faster perceived load and better SEO, at the cost of requiring an actual running server (not just static files) and added infrastructure complexity — you now have a Node process to deploy, scale, and monitor.
A full-stack React framework (the Next.js-style model) goes further: it unifies routing, data fetching, SSR, static generation, and API endpoints into one framework and one deployment artifact, often choosing per-route whether to pre-render at build time, render on each request, or render only on the client. This is the most capable and most opinionated option — it solves SEO and performance problems the plain SPA has, but commits you to that framework's conventions, build pipeline, and (often) a specific hosting platform that understands its output format.
• Static SPA: simplest hosting, cheapest, weakest initial paint/SEO, entirely client-rendered • SSR: needs a server process, best perceived performance and SEO, more moving parts • Full-stack framework (Next.js-style): most capable, most integrated, most opinionated, often platform-coupled
A typical SPA deployment configuration
The rewrite sends every unmatched path to index.html with a 200 status, which is what makes deep-linked client-side routes work on refresh. Hashed asset files under /assets get a one-year immutable cache; index.html is never long-cached so visitors always see the latest bundle references. This same pattern (fallback + differentiated cache headers) appears, with different syntax, on essentially every static host.
// vercel.json — SPA fallback + long-lived caching for hashed assets
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
],
"headers": [
{
"source": "/assets/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
},
{
"source": "/index.html",
"headers": [
{ "key": "Cache-Control", "value": "no-cache" }
]
}
]
}Code Examples
Vite automatically loads .env.production during a production build and inlines any variable prefixed with VITE_ into the client bundle at build time. The build produces content-hashed files in dist/, ready to upload to any static host or CDN origin. Expected result: dist/ containing index.html and hashed asset files
Common mistake: Putting a secret write-capable API key in a VITE_-prefixed variable — it ends up readable in plain text inside the shipped JS bundle, visible to any visitor.
Every push to main triggers this pipeline: install exact dependency versions (npm ci, not npm install, for reproducibility), lint, run the test suite, build with production environment variables sourced from encrypted repository secrets, and deploy the resulting dist/ folder. If lint or tests fail, the build and deploy steps never run, so broken code cannot reach production. Expected result: On success: new version live at the production URL. On failure: production untouched. npm ci installs exactly what's in package-lock.json and fails if it's out of sync with package.json, giving byte-for-byte reproducible installs — unlike npm install, which may update the lockfile.
Because every deployment is a distinct, immutable set of already-built static files, rolling back is just re-pointing the routing layer at an older deployment's files — it takes seconds and requires no rebuild, no git revert, and no waiting on CI. This is why keeping build artifacts immutable and content-hashed matters operationally, not just for caching. Expected result: Traffic now served from def456, no rebuild required platform-cli deployments promote def456
Common mistake: Treating 'git revert + wait for CI to rebuild and redeploy' as your only rollback plan — that can take many minutes while a broken release is live, when instant artifact promotion would take seconds.
# .env.production
VITE_API_URL=https://api.myapp.com
VITE_SENTRY_DSN=https://public-key@sentry.io/project-id
# package.json scripts
# "build": "vite build",
# "preview": "vite preview"
npm run build
# -> dist/index.html
# -> dist/assets/main.a1b2c3d4.js
# -> dist/assets/main.e5f6a7b8.cssname: Deploy
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run lint
- run: npm test -- --run
- run: npm run build
env:
VITE_API_URL: ${{ secrets.PROD_API_URL }}
- name: Deploy to hosting
run: npx some-host-cli deploy ./dist --prod
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}# Most static hosts keep every past deployment as an immutable artifact
$ platform-cli deployments list
ID DATE STATUS
abc123 2026-09-18 14:02 current
def456 2026-09-17 09:14 previous
ghi789 2026-09-15 11:47 previous
# The new deploy (abc123) broke checkout — roll back instantly:
$ platform-cli deployments promote def456
# Traffic now served from def456, no rebuild requiredStatic SPA vs. SSR vs. full-stack framework, request to paint
The three approaches trade hosting simplicity against first-paint speed and SEO; a full-stack framework lets you choose per route instead of committing the whole app to one strategy.
Static SPA
Browser -> CDN -> empty index.html + JS bundle -> React runs -> content painted
(slowest first paint, weakest SEO)
SSR
Browser -> Server renders React -> full HTML -> painted immediately -> JS hydrates -> interactive
(fast first paint, needs a running server)
Full-stack framework (Next.js-style)
Browser -> Router picks per-route strategy -> static, SSR, or client render as configured
(most capable, most opinionated, often platform-coupled)Important Rules
Always deploy the production build (npm run build output), never the dev server — dev builds are larger, slower, and expose extra debugging surface.
Configure SPA fallback (serve index.html with 200 for unmatched paths) or deep-linked and refreshed client-side routes will 404.
Cache hashed asset files (JS/CSS) aggressively and forever; cache index.html briefly or not at all so new deployments are picked up.
Never place secrets or write-capable API keys in client-exposed environment variables — they ship in plain text inside the bundle.
Serve everything over HTTPS in production; most hosts provision this automatically at no cost.
Keep every deployment as an immutable artifact so rollback is a routing change, not a rebuild.
Run tests and linting in CI before build and deploy so broken code cannot reach production automatically.
Why this matters
A React app that only runs correctly on 'npm run dev' on your laptop is not a shipped product. In real teams, deployment configuration (fallback routing, cache headers, environment variable plumbing, CI gates, rollback speed) is often the difference between a five-minute incident and a multi-hour outage. Interviewers and senior engineers routinely probe for this knowledge specifically because it separates people who've only ever run a dev server from people who've operated a real production frontend.
Caching index.html with a one-year max-age alongside the hashed JS bundles.
Cache index.html with no-cache (or a short max-age) while caching the hashed assets it references for a year.
If index.html is cached long-term, visitors' browsers keep serving an old HTML file pointing at old, possibly-deleted bundle filenames long after you've shipped a new version — effectively freezing some users on a stale release.
Deploying straight to a static file server with no SPA fallback rule configured.
Configure the host to serve index.html (status 200) for any path that isn't a real static file.
Without the fallback, any client-side route works fine when navigated to via a link, but returns a 404 the moment a user refreshes the page or opens that URL directly.
const key = import.meta.env.VITE_STRIPE_SECRET_KEY; // a secret, write-capable key
Keep secret keys server-side only; expose to the client only public, read-only keys explicitly designed for browser use.
Anything read into a VITE_-prefixed (or REACT_APP_-prefixed) variable is compiled into the public JS bundle and is trivially visible to anyone who opens browser dev tools.
Why should index.html be cached briefly (or not at all) while hashed JS/CSS bundles are cached for a very long time? A. HTML files are always smaller and load faster uncached, so caching them provides no benefit B. index.html is the file that references the current bundle filenames; caching it long-term can strand visitors on outdated bundle references, while hashed files are safe to cache forever because a content change produces a new filename C. Browsers refuse to cache HTML files by design, so setting a cache header on index.html has no effect D. CDNs cannot serve HTML content, only JS and CSS, so the cache header is irrelevant
B. index.html is the file that references the current bundle filenames; caching it long-term can strand visitors on outdated bundle references, while hashed files are safe to cache forever because a content change produces a new filename — Because hashed filenames change whenever content changes, long caching them is always safe. index.html, by contrast, is the pointer to the current hashes, so it must be re-fetched promptly after each deploy or visitors keep loading an old app shell.
What problem does SPA fallback routing solve? A. It speeds up the initial JavaScript bundle download B. It prevents client-side route changes from re-rendering the whole page C. It ensures a static file server returns index.html (with status 200) for client-side routes that have no matching file on disk, so refreshing or directly visiting a deep link works D. It automatically generates sitemaps for search engines
C. It ensures a static file server returns index.html (with status 200) for client-side routes that have no matching file on disk, so refreshing or directly visiting a deep link works — Client-side routes like /dashboard exist only in the browser's JavaScript router. Without a fallback rule telling the server to serve index.html for unmatched paths, a direct request or page refresh at that URL 404s because no such file exists.
Why is it dangerous to put a secret, write-capable API key into a Vite VITE_-prefixed or CRA REACT_APP_-prefixed environment variable? A. Those variables are automatically encrypted so there's actually no risk B. The build tool would refuse to compile and throw an error C. It only affects local development and never reaches production D. The value is inlined as plain text into the public JavaScript bundle shipped to every visitor's browser, so anyone can read it via browser dev tools
D. The value is inlined as plain text into the public JavaScript bundle shipped to every visitor's browser, so anyone can read it via browser dev tools — Client-exposed environment variables are baked into the bundle at build time as plain, readable strings. Any visitor can open dev tools and find them, so only public, read-only, or non-sensitive values belong there.
How does a fully static SPA differ from a Server-Side Rendered (SSR) app in terms of first paint and infrastructure? A. An SPA renders content only after JavaScript downloads and runs on the client, needing just a static file host; SSR renders HTML per-request on a server for a faster first paint, but requires running server infrastructure B. They are functionally identical in every respect; the terms are interchangeable C. SSR cannot use React Router while an SPA can D. An SPA requires a database server while SSR does not
A. An SPA renders content only after JavaScript downloads and runs on the client, needing just a static file host; SSR renders HTML per-request on a server for a faster first paint, but requires running server infrastructure — A static SPA ships an essentially empty HTML shell that React fills in client-side after the JS bundle loads and runs, which is simple to host but slower to first paint. SSR runs React on a server to produce ready-to-paint HTML per request, improving perceived speed and SEO at the cost of needing a live server process.
You've built a React SPA with React Router and deployed it to a static host. Users report that clicking links inside the app works fine, but refreshing the page on /profile/42 or bookmarking it and opening it later shows a 404. Diagnose the cause and describe the fix, including what HTTP status the fix should return.
The static file server has no real file at /profile/42 — that route exists only inside client-side JavaScript. Client-side navigation never hits the server for a new document, so the bug is invisible there, but a direct request (refresh, bookmark, shared link) asks the server for a resource that doesn't exist, and the server correctly 404s. The fix is an SPA fallback rule: configure the host to serve index.html with a 200 status for any request that doesn't match a real static file, so the browser loads the app shell, React Router reads the URL, and renders /profile/42 client-side.
Think about what actually exists on disk at build time versus what exists only inside client-side JavaScript after React Router takes over.