Topic 23.3
Deploying a React App
In one line
A client-side React app is a folder of static files, so any static host can serve it, as long as it answers unknown paths like /products/42 with index.html (the SPA fallback) and caches hashed assets for a year while always revalidating index.html; Vercel, Netlify and nginx in Docker each express those two rules in their own config.
Think of it like this
A shop with one front door and a guide inside. Whatever room a customer asks for ("the shoe section, please"), the doorman always lets them in through the same front door, and the guide inside walks them to the right room. If the doorman instead says "there's no door called shoe section" and sends them away, nobody can ever get there directly. The front door is index.html, the guide is your router, and the doorman is the web server.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Static host
- A server or service that only sends existing files, with no app code running.
- CDN
- Content delivery network: many servers around the world that keep copies of your files close to users.
- Deep link
- A URL that points inside the app, like
/products/42, opened directly or by reloading. - SPA fallback (rewrite)
- A server rule that answers unknown paths with
index.html(status 200) so the client router can handle them. - Cache-Control
- The response header that tells browsers and CDNs whether and how long to keep a copy.
- immutable
- A
Cache-Controlhint: this URL's content will never change, so don't revalidate it. - no-cache
- Keep a copy, but check with the server before using it (usually answered with a quick 304).
- Multi-stage build
- A Dockerfile that builds in one image and copies only the results into a smaller final image.
- Preview deployment
- A temporary URL with the build of one branch or pull request, for review before release.
Step by step
01Reload a deep link without a fallback
The difference between clicking a link and reloading is who handles the URL. A click stays inside the browser; a reload asks the server.
02Vercel: rewrites and headers
Put vercel.json at the project root. Vercel checks real files before rewrites, so assets are unaffected by the catch-all.
{
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }],
"headers": [
{
"source": "/assets/(.*)",
"headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
}
]
}03Netlify: _redirects and _headers
Put both files in public/ so Vite copies them into dist/. The status 200 turns the redirect into a rewrite: the URL in the address bar stays /products/42.
# public/_redirects
/* /index.html 200
# public/_headers
/assets/*
Cache-Control: public, max-age=31536000, immutable04Docker: build in Node, serve with nginx
Stage one installs dependencies and builds. Stage two starts from a clean nginx image and copies only dist/ and the server config. Add a .dockerignore with node_modules and dist so the build context stays small. Pin image versions your team has tested; the tags below are examples.
# Stage 1: build
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: serve
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8005nginx: fallback plus cache rules
try_files is the SPA fallback. The /assets/ block answers a missing file with a real 404 instead of HTML, and marks hashed files immutable.
server {
listen 80;
root /usr/share/nginx/html;
# hashed files: cache for a year, never fall back to index.html
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# everything else: real file if it exists, otherwise the app
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
}06Check it after the first deploy
Reload a deep link, look at the response headers of index.html and one asset, and request a missing asset. With curl -I you see headers only.
$ curl -sI https://shop.example.com/products/42 | head -1
HTTP/2 200
$ curl -sI https://shop.example.com/assets/index-D9PM2J-I.js | grep -i cache-control
cache-control: public, max-age=31536000, immutable
$ curl -sI https://shop.example.com/ | grep -i cache-control
cache-control: no-cache
$ curl -sI https://shop.example.com/assets/missing.js | head -1
HTTP/2 404Try it yourself
- 1
Forget to exclude assets
In the first example, remove
&& !path.startsWith("/assets/")from the fallback rule. Predict the line for/assets/old-chunk.js. On a real host, what would the browser do with an HTML page where it expected JavaScript? - 2
Hash the favicon too
In the cache example, rename the favicon to
/assets/favicon-a1b2c3d4.svgin both lists. Which rule does it match now, and what disappears from the last line? - 3
Deploy for real
Push a Vite app with React Router to a Git repository and import it on Vercel or Netlify. Open a deep link and reload: does it 404? Add the rewrite file, redeploy, and check the headers with your browser's Network panel.
Code & diagrams
Each box is one page reload. `MemoryRouter` with `initialEntries` plays the part of `BrowserRouter` reading the address bar. Without the fallback the server's 404 wins and React never runs. With it, the router handles both the real page and its own "not found" page, while a missing asset still gets an honest 404.
Preview
Console
Nothing logged yet.Expected console output
fallback OFF GET /products/42 -> 404
fallback ON GET /products/42 -> 200 /index.html
fallback ON GET /nope -> 200 /index.html
fallback ON GET /assets/old-chunk.js -> 404
headings: Product 42 | Page not found (from the router)Only the changed entry chunk gets a new name, so a returning user re-downloads about 70 kB gzipped instead of everything. `index.html` is revalidated (usually a tiny `304 Not Modified`) and `config.json` is fetched fresh. The favicon has a short ordinary cache because its name has no hash.
Preview
Console
Nothing logged yet.Expected console output
/index.html => no-cache
/assets/index-D9PM2J-I.js => public, max-age=31536000, immutable
/assets/Chart-CC2n_YEU.js => public, max-age=31536000, immutable
/assets/index-nqMpL4T3.css => public, max-age=31536000, immutable
/config.json => no-store
/favicon.svg => public, max-age=3600
after deploy, not from the long cache: /index.html /assets/index-Bd1YY8Ma.js /config.json /favicon.svgThe first lines are React's development message for an error caught by a boundary (Topic 23.5 shows how to send it to a reporter instead). The boundary turns a broken screen into a clear next step. Don't click Reload in the preview: it reloads the preview frame.
Preview
Console
Nothing logged yet.Expected console output
TypeError: Failed to fetch dynamically imported module: https://shop.example/assets/Reports-Bq31x9.js
The above error occurred in one of your React components.
React will try to recreate this component tree from scratch using the error boundary you provided, ChunkBoundary.
page: ReportsA new version is available.Reloadasync headers() {
return [
{
// React for the live previews: the path carries the React version, so the files never change.
source: "/runner/react/:path*",
headers: [
{ key: "Access-Control-Allow-Origin", value: "*" },
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
],
},
];
},Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
No SPA fallback on the host
Deploy a React Router app to a static host without any rewrite rule, open /products/42 and press reload.
Break #2
A fallback that also catches assets
Use a catch-all that returns index.html for every missing path, including /assets/*, then deploy while a user has the old version open.
Break #3
Cache index.html for a year
Apply Cache-Control: public, max-age=31536000, immutable to every file, including index.html.
Myth vs fact
Myth
React apps need a Node.js server in production.
Fact
A Vite client app is static files. You need a server only for SSR, Server Components, APIs or secrets.
Myth
no-cache means "don't cache".
Fact
It means "cache, but revalidate before each use". no-store is the one that forbids storing.
Myth
If it works with vite preview, the host will route deep links the same way.
Fact
vite preview falls back to index.html by itself. Most static hosts need an explicit rewrite.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Use a rewrite (status 200, URL unchanged), not a redirect (301/302 to
/), for the SPA fallback. A redirect loses the path, so the router can't render the page the user asked for. - ▸
The fallback returns 200 for URLs that don't exist in your app either, which search engines may index as "soft 404s". Client-only apps can't send a real 404 status; if that matters for SEO, use SSR or prerendering (Phase 20) where the server knows which routes exist.
- ▸
Compression matters as much as caching: make sure the host serves Brotli or gzip for JS, CSS, JSON and SVG. Vercel, Netlify and CDNs do it automatically; nginx needs
gzip on(Brotli needs an extra module). - ▸
Security headers belong in the same config as cache headers:
Content-Security-Policy,X-Content-Type-Options: nosniff,Referrer-PolicyandStrict-Transport-Security. A strict CSP is one of the best defences against XSS (Topic 21.6 covers CSP for React apps).
Remember this
- 1
Static hosting.
vite buildproduces plain files:index.htmland hashed JS and CSS inassets/. A static host (Netlify, Vercel, Cloudflare Pages, GitHub Pages, an S3 bucket behind a CDN, or nginx) only needs to send files when asked. A CDN (content delivery network) copies them to servers around the world, so users download from somewhere close. No Node.js process runs in production for a client-only app; servers matter for SSR, Server Components and APIs (Phase 20). - 2
Why deep links break. With client-side routing (Phase 13), the URL
/products/42exists only inside your JavaScript. Clicking a<Link>never asks the server for that path. But reloading the page, opening a shared link or a bookmark sendsGET /products/42to the server, and there's no file calledproducts/42, so a plain static host answers 404. React never loads, so your router's own "not found" page never shows. - 3
The SPA fallback (also called a rewrite) tells the server: if a request doesn't match a real file, send
index.htmlwith status 200 instead. The browser loads the app, the router reads the URL and renders the product page. Two details matter. First, real files must still be served first, so/assets/index-D9PM2J-I.jsis the JS file, not HTML. Second, a missing asset should stay a 404: if/assets/old.jsreturnsindex.html, the browser gets HTML where it expected JavaScript and printsFailed to load module script: Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of "text/html".Note thatvite previewalready applies the fallback, so a missing rewrite only shows up on the real host: test a deep-link reload after every first deploy. - 4
Caching hashed assets. Browsers and CDNs keep copies of files according to the **
Cache-Control** header. Files inassets/have content hashes in their names (Topic 23.1), so a given URL's content never changes: serve them withCache-Control: public, max-age=31536000, immutable(one year, and "don't even ask again").index.htmlmust be the opposite:no-cache, which means "you may store it, but check with the server before each use" (cheap, thanks toETagand a304 Not Modifiedanswer). After a deploy, users get the newindex.html, which points at the new hashed files; unchanged chunks keep their names and stay cached. Runtimeconfig.json(Topic 23.2) getsno-store. - 5
Vercel and Netlify detect Vite and run
npm run build, publishingdist/. Both serve real files first and apply your rewrites only when no file matches. On Vercel, avercel.jsonwithrewritesfrom/(.*)to/index.htmlgives the SPA fallback, andheaderssets caching. On Netlify, apublic/_redirectsfile with/* /index.html 200does the same (the200makes it a rewrite, not a redirect), and apublic/_headersfile sets headers. Files inpublic/are copied intodist/as they are. Both platforms also give each pull request a preview deployment with its own URL. Next.js apps (like Hectal itself) need no SPA fallback: Next.js has a server or prerendered HTML for every route, and its config sets headers with an asyncheaders()function innext.config.ts. - 6
Docker with nginx. For your own servers or Kubernetes, a multi-stage Dockerfile builds the app in a Node.js image, then copies only
dist/into a small nginx image. The final image has no Node.js, nonode_modulesand no source code: it's small and has less to attack. nginx'stry_files $uri $uri/ /index.html;is the SPA fallback (try the file, then a folder, thenindex.html), and separatelocationblocks set cache headers. Remember Topic 23.2:VITE_values are frozen into the image when it's built. - 7
Subfolders, old chunks and rollbacks. If the app lives under a path (
https://example.com/shop/, or GitHub Pages'/repo-name/), set Vite'sbase: "/shop/"and the router'sbasename. A user who keeps an old tab open still runs the old code; when they navigate, it asks for an old lazy chunk that a new deploy may have deleted (TypeError: Failed to fetch dynamically imported module). Keep old assets for a while after deploying (most platforms keep each deployment's files immutable), and catch chunk errors with an error boundary that offers a reload. Atomic deploys and instant rollbacks (switching back to the previous deployment) are the main reason to prefer a platform or a CDN with versioned uploads over copying files onto a server by hand.
Explain it without notes
Why does reloading /products/42 give a 404 on some hosts while clicking a link to it works?
Describe the caching strategy for a Vite build and why it's safe.
Walk through a multi-stage Dockerfile for a React app served by nginx.
What happens to users with an old tab open when you deploy, and how do you handle it?
Practice
Write the Netlify files for a Vite app with React Router: an SPA fallback and long caching for /assets/*.
Write an nginx server block for an app deployed under /shop/ (Vite base: "/shop/").
Trade-offs
- ↔
Platforms like Vercel and Netlify give previews, atomic deploys, rollbacks and a CDN with no setup, at the cost of vendor-specific config and pricing; nginx in Docker gives full control and portability but you own scaling, TLS and the CDN.
- ↔
Long immutable caching makes repeat visits fast but means you must never change a hashed file in place; keeping old assets around helps old tabs but costs storage.
Done when you can
I can explain why deep links 404 without an SPA fallback and add one on Vercel, Netlify and nginx.
I can set
immutablecaching for hashed assets andno-cacheforindex.html.I can write a multi-stage Dockerfile that serves
dist/with nginx.I handle missing chunks after a deploy with real 404s and an error boundary.
I verify a deploy by reloading a deep link and reading the response headers.