Deploying a Vue.js SPA the right way
A Vue single-page application (SPA) is, at the end of the build, just a folder of static files: an index.html, a hashed JavaScript bundle, CSS, and assets. That sounds trivial to host — and it is, until you hit the three things that bite every Vue developer in production: client-side routing 404s, stale caches, and environment variables that were baked in at the wrong time.
This tutorial walks through deploying a Vite-based Vue 3 app correctly, with the gotchas spelled out. The same approach works for Vue 2 + Vue CLI with minor path changes.
Step 1: Produce a clean production build
With a Vite-scaffolded project (npm create vue@latest), the production build command is:
npm install
npm run buildThis emits a dist/ directory. Inspect it before you deploy anything:
ls -la dist
# index.html
# assets/index-a1b2c3d4.js
# assets/index-e5f6g7h8.cssThe hashed filenames matter. Vite fingerprints every asset so you can cache them forever and only index.html ever needs to be revalidated. Hold that thought — it drives the caching strategy below.
If you use Vue CLI instead, the command is npm run build and the output folder is also dist/ by default.
Step 2: Fix SPA routing (the #1 production bug)
Vue Router in history mode produces clean URLs like /dashboard/settings. The problem: when a user refreshes that page or shares the link, the browser asks the server for /dashboard/settings — a file that does not exist on disk. Static hosts return a 404.
The fix is a SPA fallback: every unknown route must serve index.html, and Vue Router takes over on the client.
If you self-host with Nginx:
location / {
try_files $uri $uri/ /index.html;
}With Apache, a .htaccess rewrite:
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]On a managed static host like PandaStack, SPA fallback is detected automatically for known frameworks — connect the repo, pick the static app type, and unknown routes resolve to index.html without you writing rewrite rules. If you ever need hash mode instead (createWebHashHistory), URLs become /#/dashboard and no fallback is required, but you lose clean URLs and slightly hurt SEO.
Step 3: Get caching headers right
The single biggest performance lever for a SPA is cache policy, and it follows directly from Vite's hashing:
| Asset | Cache-Control | Why |
|---|---|---|
assets/*.js, assets/*.css | public, max-age=31536000, immutable | Filename changes on every content change |
index.html | no-cache (or short max-age) | Entry point must always reflect the latest bundle |
| Images/fonts (hashed) | public, max-age=31536000, immutable | Same fingerprint guarantee |
Get this backwards — caching index.html aggressively — and users will load an old HTML file that references deleted, hashed bundles, producing white screens after every deploy. This is the most common "it works on my machine but broke for users" SPA incident.
Step 4: Handle environment variables correctly
Vite exposes env vars prefixed with VITE_ at build time, not runtime:
# .env.production
VITE_API_BASE_URL=https://api.example.comconst api = import.meta.env.VITE_API_BASE_URLKey implication: these values are compiled into the static bundle. You cannot change them after the build without rebuilding, and you must never put secrets here — anything in VITE_* is shipped to the browser in plain text. Use it only for public configuration (API base URLs, feature flags, public keys). Real secrets belong in your backend.
On a CI/CD platform, set VITE_* variables in the build environment so each deploy bakes in the right config. PandaStack injects environment variables into the build job, so your npm run build sees them and the produced bundle is environment-correct.
Step 5: Deploy
If you are doing it by hand, you rsync dist/ to a server or aws s3 sync dist/ s3://bucket plus a CloudFront invalidation. That works but you own the SPA fallback, the cache headers, the SSL cert renewals, and the CDN.
The git-push path looks like this:
git add .
git commit -m "feat: production build"
git push origin mainConnect the repository to PandaStack as a static site, and it auto-detects Vite, runs npm install && npm run build, serves dist/ from a multi-region CDN behind Kong ingress with Cloudflare DNS, applies the SPA fallback, and issues an automatic SSL certificate for your custom domain. Static builds run inside microVMs on pandastack.ai. You override the install command (npm/yarn/pnpm/bun) if your project needs it.
Step 6: Add a custom domain and SSL
Point a CNAME (or apex ALIAS/flattened record) at your host and let it provision the certificate. Verify HTTPS is enforced and HTTP redirects to HTTPS — Vue apps frequently load resources over mixed protocols if you forget this.
curl -I https://app.example.com
# HTTP/2 200
# cache-control: no-cacheCommon pitfalls checklist
- White screen after deploy →
index.htmlwas cached; set it tono-cache. - 404 on refresh of a sub-route → missing SPA fallback to
index.html. - API calls hit the wrong environment →
VITE_*baked at build with the wrong values. - Assets 404 under a subpath → set Vite
baseinvite.config.jsif you deploy under/app/instead of root. - Leaked secret → you put a private key in a
VITE_variable; rotate it now.
References
- [Vite — Building for Production](https://vite.dev/guide/build.html)
- [Vite — Env Variables and Modes](https://vite.dev/guide/env-and-mode.html)
- [Vue Router — Different History Modes](https://router.vuejs.org/guide/essentials/history-mode.html)
- [MDN — Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control)
If you would rather not hand-write rewrite rules and cache policies, PandaStack's free tier hosts static sites with automatic SPA fallback, immutable asset caching, and free SSL out of the box. Spin one up at [dashboard.pandastack.io](https://dashboard.pandastack.io).