Refreshing /orders/42 Returns 404 Until the Server Learns Your Router
A tester taps a link to https://app.acme.test/orders/42, the page loads in the app, she hits refresh — and gets a bare 404 from nginx. The route works perfectly when you navigate to it from the dashboard, because then no server request for /orders/42 ever happens: vue-router intercepts the click and renders locally. The direct load is different — the browser asks the server for that exact path, and the server has no file by that name.
A single-page app has exactly one page on disk: index.html. Every other URL your router defines exists only in JavaScript. So every SPA host needs the same contract: any path that is not a real file must return index.html with a 200, and the router takes over from there.
nginx expresses it in one line:
location / {
try_files $uri $uri/ /index.html;
}
$uri tries the literal file, $uri/ tries a directory index, and only then does the fallback serve index.html — the router boots, matches /orders/42, and renders the view. Other hosts say the same thing differently:
// vercel.json
{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }
# public/_redirects (Netlify)
/* /index.html 200
# .htaccess (Apache)
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
The !-f / !-d conditions (nginx's try_files, Netlify's rule ordering, Vercel's filesystem handler) are the part people drop — and that is when the damage starts. If the fallback fires before the file check, a missing JavaScript asset returns index.html with a 200, and the browser reports Expected a JavaScript module script but the server responded with a MIME type of "text/html" instead of a clean 404. Guard the asset directory explicitly:
location /assets/ { try_files $uri =404; } # stale chunk = real 404
location / { try_files $uri $uri/ /index.html; }
API paths need the same protection in the other direction. When /api/orders falls through to index.html, your fetch receives HTML and dies with the beloved Unexpected token '<', "<!doctype html>…" is not valid JSON — a server misconfiguration that looks exactly like a frontend bug. In nginx, location /api/ { proxy_pass http://backend; } placed above the fallback keeps the SPA rule out of API traffic; on Vercel and Netlify, API routes are declared before the catch-all rewrite.
There is a deployment where none of this is possible: a plain static host with no rewrite support and no place to drop a fallback file — an S3 bucket without website hosting, a customer's internal file server. The escape hatch is the routing mode itself. createWebHashHistory() moves the route into the fragment (/index.html#/orders/42), fragments are never sent to the server, and the whole fallback problem disappears. The cost is visible # URLs and a routing model that some link previews and crawlers handle worse — which makes hash mode a deliberate fallback for hosts you do not control, not a default for your own deployment.
Order your rules deliberately: real files and directories first, API and proxy paths second, explicit legacy redirects (301) third, index.html fallback last. Test the matrix with curl -I before shipping: the deep link returns 200 text/html, a fabricated /assets/nope-123.js returns 404, /api/orders returns JSON with its own content type, and index.html itself is served fresh.
There is one more consequence of the fallback that bites at review time: every unknown URL answers 200, including /this-does-not-exist. Crawlers record a soft 404, and your analytics see phantom "successful" pages. Whether the fallback can return a true 404 status depends on how the app is delivered — prerendering and SSR can emit real ones, pure static hosting generally cannot. That trade-off connects directly to Production Build Optimization, where you decide how much of the app is rendered ahead of time — and the capstone at the end of the path, Capstone: A Production SPA, wires all of this into a real deployment pipeline.
Moving the App Under example.com/app/: What base Rewrites and What It Cannot
The rewrite rule works, the app boots at the domain root — and then you move it under https://example.com/app/ for a customer portal and get a blank page. View source: index.html is asking for /assets/index-a91d0f33.js, the server answers with a 404 (or, worse, the HTML fallback with the wrong MIME type), and nothing ever mounts. The file exists — at /app/assets/index-a91d0f33.js.
Vite emits asset URLs as root-absolute paths by default, so the deployment path must become part of the build:
// vite.config.js
export default defineConfig({
base: '/app/', // must start (and normally end) with '/'
build: { outDir: 'dist' }
})
Every reference in the generated index.html — script tags, stylesheet links, CSS url() assets, the modulepreload list — is rewritten to /app/assets/.... That is half the fix. The other half is the router, because with createWebHistory() the base is /:
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), // '/app/'
routes: [...]
})
import.meta.env.BASE_URL is replaced at build time with the base value, so one config change moves assets and route matching together. Miss it and you get the signature mismatch: assets load fine, but opening /app/orders/42 makes vue-router try to match /orders/42 against your root — no match, blank router-view, no error in the console.
| Symptom after moving under a sub-path | Cause |
|---|---|
Blank page, 404s on /assets/*.js |
base not set — HTML still points at /assets/ |
| Assets load, direct URLs render nothing | router history created with / instead of BASE_URL |
| Images/CSS fine, favicon 404 | asset referenced from public/ with a hardcoded /favicon.ico |
| Works locally, fails only on the customer portal | dev server runs at /, production runs at /app/ |
Verify a sub-path build the same way you verify headers — with the built files, not with faith:
grep -o 'src="[^"]*"' dist/index.html # every reference must start with /app/
One grep tells you whether base actually applied. vite preview also serves your build at the configured base, so the mismatch surfaces locally before it surfaces in the customer portal.
Files in public/ deserve their own warning: they are copied verbatim, and any path you wrote by hand inside them (/logo.svg) does not get the base prepended. Reference them as `${import.meta.env.BASE_URL}logo.svg` — that string concatenation is the same in dev and production.
Which brings up the tempting shortcut: base: './' (relative). It works for hash-mode routing or a single self-contained HTML file, and it breaks history mode the moment a user refreshes a nested route — from /app/orders/42, a relative ./assets/index.js resolves to /app/orders/42/assets/index.js, which does not exist. With history mode, the base must be absolute; only createWebHistory + absolute base guarantees the same URL at every depth.
Static hosts that cannot rewrite add one more twist: GitHub Pages project sites serve /<repo>/ and answer unknown paths with a 404.html — dropping a copy of your built index.html there is the standard SPA workaround, alongside base: '/<repo>/'.
Notice what is not in this file: your API origin. Backend URLs are configuration, not build layout — they come from prefixed environment variables, which is exactly what Environment Variables in Vite covers. Get base and API config mixed into one hardcoded constant and every environment needs its own rebuild for the wrong reason.
Deploy Day Headers: Immutable Assets, a Never-Cached index.html, and Precompressed Bytes
Your team deployed a fix at 14:00 and at 14:05 support is flooded: users see a blank screen or yesterday's UI, and clearing cookies does nothing. The assets were not the problem — index.html was. Someone gave it Cache-Control: max-age=31536000, so browsers that fetched it last week are still serving last week's copy, pointing at hashed chunks the release already replaced.
The caching scheme for an SPA is a two-sided rule, and both sides exist to make the other one safe:
| URL | Cache-Control | Why |
|---|---|---|
/index.html |
no-cache (or max-age=0, must-revalidate) |
it is the pointer to the current release — must be revalidated every time |
/assets/*.js, /assets/*.css (content-hashed) |
public, max-age=31536000, immutable |
the name changes when the content changes, so it never needs revalidation |
/api/* |
no-store |
user data must not sit in a shared cache |
/favicon.ico, /logo.svg (unhashed) |
no-cache or short max-age |
filenames never change, so content can |
location = /index.html { add_header Cache-Control "no-cache"; }
location /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; }
location /api/ { proxy_pass http://backend; add_header Cache-Control "no-store"; }
immutable is only legal because the filename contains a content hash — that pairing is the entire mechanism. Get it backwards (assets no-cache, HTML max-age=31536000) and you optimize for the worst case twice: every visit re-downloads the vendor bundle, and every deploy strands users on a broken release.
Caching interacts with the fallback rule from chapter 1, which is why the final rule order matters: files first, then API paths, then the index.html fallback. Verify the whole contract from a cold shell with curl -I:
curl -I https://app.example.com/orders/42 # 200 text/html, no-cache
curl -I https://app.example.com/index.html # 200 text/html, no-cache
curl -I https://app.example.com/assets/index-a91d0f33.js
# 200 application/javascript, immutable
curl -I https://app.example.com/assets/gone.js
# 404 — NOT text/html
curl -I https://app.example.com/api/orders # backend's status + JSON content type
Transfer size is the other half of deploy-day performance. Text compresses hard: your 96 KB minified index-a91d0f33.js lands around 31 KB over gzip and roughly 27 KB over brotli. Where the compression happens is a hosting decision — a CDN like Cloudflare compresses on the fly, while a self-hosted nginx wants files precompressed at build time so it never spends CPU on it:
gzip_static on; # serves index-a91d0f33.js.gz if present
brotli_static on; # ... and the .br sibling
If you precompress, generate the .gz/.br siblings in the build (a postbuild script with gzip -9 -k and brotli -q 11, or a Vite compression plugin) — otherwise gzip_static silently serves nothing. Two details complete the picture: self-terminating TLS should emit Vary: Accept-Encoding so a shared cache never hands a .br file to a gzip-only client (browsers treat the mismatch as a decode failure), and HTTP/2 has removed the old one-request-per-file penalty — dozens of small lazy chunks cost little beyond headers, so concatenating files for "fewer requests" is an optimization from a protocol generation that no longer applies.
Finally, redeploy hygiene: keep the previous release's assets/ directory reachable for 24–48 hours. Sessions opened before the deploy still reference old hashes, and lazy chunks load minutes or hours later; without that grace window those users meet Failed to fetch dynamically imported module the first time they open a chart. Ship the rewrite rules, the header table, the compression siblings, and the grace window together, and your deployment story is complete enough to build Capstone: A Production SPA on top of it.
Quiz
1. An nginx SPA deployment suddenly answers `Unexpected token '<', "<!doctype html>…" is not valid JSON` on every API call. What is wrong?
2. After deploying to https://example.com/app/ the page is blank and the console shows 404s for /assets/index-a91d.js. What did you miss?
3. Which header pairing is correct for a hashed-asset SPA?
4. During deploy verification you want to confirm the SPA fallback does not mask missing JavaScript. Which check proves it?
Flashcards
Question
What does a history-mode SPA require from the hosting server on a direct load of /orders/42?
Click to reveal answer
Answer
Every path that is not a real file must return index.html with a 200 so the router can resolve it: nginx `try_files $uri $uri/ /index.html;`, Vercel `rewrites` to /index.html, Netlify `/* /index.html 200`. In-app navigation never touches the server; only direct loads do.
Question
What is the correct rule order for an SPA host, and what breaks if it is wrong?
Click to reveal answer
Answer
Real files/directories → API/proxy paths → legacy 301 redirects → index.html fallback. Wrong order turns missing JS assets into 200 text/html (MIME errors instead of 404s) and API calls into HTML (the `Unexpected token '<'` JSON parse failure).
Question
What two settings must change together when the app moves from / to /app/ ?
Click to reveal answer
Answer
Vite `base: '/app/'` (rewrites every asset URL in index.html and defines import.meta.env.BASE_URL) and `createWebHistory(import.meta.env.BASE_URL)` (scopes route matching to the same prefix). Changing only one yields either 404ing assets or routes that match nothing.
Question
What is the cache contract for a hashed-asset SPA?
Click to reveal answer
Answer
index.html: no-cache / must-revalidate (it points at the current release). Hashed assets: public, max-age=31536000, immutable — safe because the content hash changes with the content. API: no-store. Unhashed static files (favicon, logo): no-cache or short max-age.
Question
Why keep the previous release's assets/ folder alive for 24–48 hours after a deploy?
Click to reveal answer
Answer
Sessions opened before the deploy still hold an index.html (or lazy-chunk references) pointing at old hashed filenames; those files must keep answering or the next lazy import throws `Failed to fetch dynamically imported module`. Pair it with a single guarded reload on chunk error.
Revision Notes
Key Takeaways
- 1. The SPA fallback is a contract — non-files return index.html with 200 — and it must run strictly after real files and API paths or you get MIME errors and JSON-parse failures that look like frontend bugs
- 2. Direct loads are the only requests the server sees for client routes; that is why refresh on a deep link is the canonical deployment test
- 3. base and the router history base are two halves of one setting: assets 404 with only routing fixed, routes render blank with only assets fixed
- 4. Caching is two-sided: never-cache index.html makes one-year immutable hashed assets safe; reversing either side strands users on stale releases or re-downloads everything
- 5. Deploy resilience means precompressed siblings, a curl-based header matrix, and a 24–48 hour grace window for old hashed chunks
Interview Tips
- • "How does a SPA handle a refresh on a deep link?" — walk the whole path: server has no file → ordered fallback returns index.html 200 → router boots and matches; name the exact directive (`try_files`) you would write
- • "What header would you put on index.html and why?" — no-cache/must-revalidate: HTML is a pointer to the current release; follow immediately with immutable on hashed assets to show you understand the pairing, not just the two headers
- • "Our users see a blank page after deploy — diagnose it" — order the hypotheses: stale cached index.html, base mismatch after moving paths, removed old assets causing chunk 404s; each has a distinct check (view source, curl -I)
- • "Deploying under a sub-path — what changes?" — base + createWebHistory(BASE_URL) + prefix public assets; mention relative base breaking nested history-mode refreshes as the trap you would avoid
Cheat Sheet
Deploying a Single-Page App
History-mode fallback (rule order matters):
location /assets/ { try_files $uri =404; } # 1. real files
location /api/ { proxy_pass http://backend; } # 2. API
# 3. explicit legacy 301s
location / { try_files $uri $uri/ /index.html; } # 4. fallback
// vercel.json
{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }
# Netlify public/_redirects
/* /index.html 200
Sub-path deploy: base: '/app/' in vite.config + createWebHistory(import.meta.env.BASE_URL). Never use base: './' with history mode — nested refreshes resolve assets against the route depth. public/ assets need explicit ${import.meta.env.BASE_URL} prefixing.
Cache contract:
| URL | Header |
|---|---|
index.html |
no-cache |
hashed assets/* |
public, max-age=31536000, immutable |
/api/* |
no-store |
| unhashed static | no-cache / short max-age |
Verify: curl -I deep link → 200 text/html · fake asset → 404 · real hashed asset → immutable · /api/* → JSON content type.
Transfer: precompress .gz/.br siblings in build → gzip_static on; brotli_static on;. Keep the previous release's assets/ for 24–48 h so in-flight sessions don't hit Failed to fetch dynamically imported module.