The constraint in one line
StaticHost serves files with nginx logic equivalent to try_files $uri $uri/ =404. There is no SPA fallback that maps unknown paths to /index.html. If you deploy a browser-router app and someone refreshes /dashboard/settings, the server looks for that file, fails, and returns 404.
This is intentional product behavior, not a missing checkbox. This guide explains how to host SPAs anyway—with hash routing or with generated files per route—and how to stop copying _redirects advice from other vendors.
What an SPA upload contains
Typically:
index.html
assets/index-….js
assets/index-….css
One HTML shell; JavaScript paints the UI and owns routing. On hosts with fallback, every deep link serves the shell. On StaticHost, only / (and real files) work unless you change approach.
Strategy A — Hash routing (smallest change)
React:
import { HashRouter } from 'react-router-dom'
// wrap routes in HashRouter
Vue:
import { createWebHashHistory, createRouter } from 'vue-router'
createRouter({ history: createWebHashHistory(), routes })
URLs look like https://example.com/#/dashboard/settings. The browser requests /; index.html loads; the router reads the hash. Works with =404 servers. Downsides: hash URLs are slightly uglier; some analytics setups need hash listening.
Strategy B — Generate a file per route (SSG / prerender)
Use a static export or prerender step so /dashboard/settings/index.html (or equivalent) exists. Next output: 'export', SvelteKit adapter-static with prerender, Astro static routes, or prerender plugins for Vite SPAs.
Then history URLs are real files. try_files succeeds. This is the clean long-term approach for public marketing URLs.
Do not “Strategy C — invent a rewrite.” There isn’t one here.
Deploy steps (any strategy)
npm ci
npm run build
cd dist # or build/out
zip -r ../spa.zip .
Upload to StaticHost or push artifact branch—no remote npm. Verify on HTTPS preview:
- Load
/ - Navigate in-app to a deep route
- Copy that URL, open a new tab, paste, enter
- Hard refresh
For hash mode, step 3–4 should work. For history mode without files, step 3–4 404—expected. Fix strategy before ads or store listings. Rollback via deploy history if you ship the wrong mode.
Related: React, Vue, Vite, Svelte static, Next export.
APIs, auth, and CORS
SPAs call APIs elsewhere. Allowlist preview and production origins. Tokens in the browser are visible to users—use the appropriate OAuth/public key patterns. StaticHost does not run your API.
Plans
Starter $9 (1×2 GB), Pro $30 (3×10 GB + staging), Scale $65 (10×30 GB), Business $130 (30×100 GB + teams). Trial ~1 day. No forever-free, cPanel, email, built-in CDN, PHP.
When to pick another host
If hash URLs are unacceptable, SSG is impossible, and you refuse to emit files, you need a host that documents SPA fallback. Choose it knowingly—where to host, wrong choice.
Worked example: Vite + React admin that must survive refresh
You ship an internal admin at admin.example.com. Product wants clean paths like /users/42. SEO does not matter; refresh must not 404.
Path 1 — fastest ship: switch the router to HashRouter. Build, zip dist, deploy to StaticHost, open HTTPS preview, paste https://preview…/#/users/42, hard refresh—200. Attach the custom domain only after that passes. Cert issues after DNS verifies.
Path 2 — clean URLs: add a prerender step (or migrate the public subset to a static-capable metaframework) so users/42/index.html exists for routes you care about. Private, auth-gated routes that cannot be prerendered still cannot exist as files—those screens must live behind hash routes or on an app host.
What fails: uploading a CRA/Vite BrowserRouter build unchanged, then filing a ticket for “SPA mode.” StaticHost will keep returning 404 for missing paths. That is the product.
Failure table: SPA deploys on StaticHost
| Symptom | Likely cause | Fix |
|---|---|---|
/ works; refresh on /settings 404s | History mode without files | HashRouter or prerender /settings |
| Assets 404 after deploy | Wrong base / homepage | Set root base: '/' or CRA homepage; rebuild |
| Blank page on preview | JS error or nested zip | Console check; flatten zip root |
| API calls fail on custom domain only | CORS allowlist missed new origin | Allow https://your-domain |
_redirects uploaded, still 404 | Netlify syntax ≠ this nginx | Remove fantasy; change routing strategy |
| Service worker pins old shell | Aggressive caching on HTTPS | Bump SW version; unregister while debugging |
| Staging shows prod API data | Same env baked into both builds | Rebuild with distinct VITE_* / REACT_APP_* |
Print the table near your CI docs. Deep-link QA belongs in the pipeline, not in production panic.
Extra procedure: CI smoke test for routing honesty
Add a job after deploy (or against HTTPS preview) that fails the pipeline when architecture drifts:
# hash-mode expectation: deep path without hash should 404 OR you only test hash URLs
curl -s -o /dev/null -w "%{http_code}" "$PREVIEW_URL/#/settings" || true
# history-mode WITH prerender: real file must 200
code=$(curl -s -o /dev/null -w "%{http_code}" "$PREVIEW_URL/settings")
test "$code" = "200"
# history-mode WITHOUT prerender on StaticHost: document 404 as expected
code=$(curl -s -o /dev/null -w "%{http_code}" "$PREVIEW_URL/settings")
test "$code" = "404"
Pick one assertion block that matches your chosen strategy. The point is not cruelty—it is refusing to discover SPA mismatch when a customer bookmarks a screen. Combine with deploy history rollback so a bad router merge can be undone in minutes while you fix main.
Remember: StaticHost does not run npm ci. Your Actions (or other CI) builds the artifact; the host only publishes files. No forever-free tier after the short trial; plan quotas still apply to SPA shells the same as brochure sites. No email, no cPanel, no built-in CDN product—wire APIs and asset weight accordingly.
Authenticated SPAs and bookmarkability
Logged-in apps love deep links: support tickets, settings tabs, project IDs. Those links must survive refresh on StaticHost via hash routing or prerendered shells that then hydrate and check auth client-side. Prerendering a private page’s HTML skeleton is OK if it contains no secrets; the API still authorizes. Never bake access tokens into static files.
Document for QA: “open bookmark in a fresh browser profile while logged out, then while logged in.” Logged out may show a login screen shell; logged in should land on the route after auth redirect logic you control in JS. If your auth library assumes server sessions on the same origin, you are edging toward wrong choice territory. Keep APIs elsewhere, files here, and nginx’s =404 honesty in the architecture review.
FAQ
Does 404.html equal SPA fallback?
No. A custom 404 document is not a silent rewrite of all unknown paths to your app shell.
Will a _redirects file fix this?
Do not assume Netlify-style redirects apply. Treat StaticHost as =404.
Is hash routing bad for SEO?
For app UIs behind login, SEO rarely matters. For public content, prefer SSG files with history URLs.
Can I use both hash and clean URLs?
Pick one primary strategy to avoid duplicate content chaos.
Does HTTPS preview behave differently from custom domains for routing?
No. Same file rules. Domains only change the hostname and cert.