Environment variables, builds and deployment
Read per-environment values from .env files with the VITE_ prefix, know that the build bakes them in, and deploy dist/ with a fallback so deep links survive a refresh.
What you'll learn
When you finish, you can do these.- 1Read public values from .env files with VITE_ names
- 2Explain why a changed .env value needs a new build
- 3Deploy the built app so every page loads on a refresh
- 4Spot and fix a flag that stays on when set to false
Picture a notes app, a small Vue app that loads notes from a server. While you work, it asks a test server on your laptop, http://localhost:8787; on the live site, called production, it asks https://api.example.com. A beta feature, a new editor, should show on staging, a test copy of the site, and never to customers. Written into the code, those values need an edit before each deploy (putting a new version online), and one day someone forgets.
Vite, the build tool that runs a Vue project, reads them from .env files instead: small text files of settings. The Code tab holds the notes app's .env files, the code that reads them and the files that build, check and deploy the app; by the end, it is online and every page loads. You need one idea from the lesson Linting and formatting: CI, a server that runs your checks on every push.
.env files and the VITE_ prefix
A .env file holds one NAME=value per line, and only names that start with VITE_ reach your code.
Vite runs in a mode: development for pnpm dev, production for pnpm build. It loads up to four files from the project's root: .env and .env.local always, .env.[mode] and .env.[mode].local for the current mode, such as .env.production in a build. For the same name, a mode's file beats a general one, and a .local file beats the one without.
The project's .gitignore, the list of files git leaves out, ignores *.local, any name ending in .local, so those files stay on your machine. In the Code tab, .env.production holds the live address, and .env.example the laptop's: each developer copies that file to .env.local.
Your code reads such a value as import.meta.env.VITE_API_URL. A name without the prefix is left out, so it cannot leak into the browser by accident.
The reverse matters as much: every VITE_ value ends up in JavaScript anyone can download. A password or a private API key never goes in one, or in any committed file: it belongs on a server.
In the Code tab, src/config.ts gathers the values into one config object for the app:
export const config = {
apiUrl: import.meta.env.VITE_API_URL,
appName: import.meta.env.VITE_APP_NAME,
showBeta: import.meta.env.VITE_SHOW_BETA === 'true',
mode: import.meta.env.MODE,
}
Every value arrives as a string, which is why showBeta compares with 'true'. Under pnpm dev, config.apiUrl is http://localhost:8787, from .env.local; under pnpm build, .env.production wins with https://api.example.com. MODE is Vite's own value naming the mode.
Declare the variables for TypeScript in env.d.ts. With strictImportMetaEnv on, a name you did not declare is a type error instead of a silent undefined:
interface ViteTypeOptions {
strictImportMetaEnv: unknown
}
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_APP_NAME: string
readonly VITE_SHOW_BETA: string
}
The editor then completes the three VITE_ names and underlines a misspelled one.
The build bakes values in
A .env value is read once, when the app is built, and never again. vite build, which pnpm build runs, replaces each import.meta.env.VITE_… with its value, as text, and writes the finished files to dist/.
- .env files
- vite buildvalues baked in
- dist/
- hostindex.html fallback
Search dist/assets for "Notes", the value of VITE_APP_NAME, after a build: it sits inside the JavaScript. The host, the service that puts dist/ online, never reads .env, so editing .env.production after a deploy changes nothing on the live site until you build and deploy again.
Each environment gets its own build: vite build --mode staging reads a .env.staging file, where VITE_SHOW_BETA=true turns the beta feature on.
GitHub Pages, GitHub's free host, serves a project under a sub-path, such as https://you.github.io/notes/, so every script and stylesheet address needs the /notes/ prefix. The base option in vite.config.ts adds it when Vite builds.
In the Code tab, base is on only when the build:pages script in package.json sets GITHUB_PAGES. vite.config.ts reads it from process.env during the build, so no VITE_ prefix is needed:
base: process.env.GITHUB_PAGES ? '/notes/' : '/',
Without base, the site on GitHub Pages stays blank: the browser asks for /assets/… at the root of you.github.io and gets a 404.
Deploy dist/ and keep deep links working
Any static host, one that only sends files, can put dist/ online: Netlify, Vercel, Cloudflare Pages, GitHub Pages. Give it the build command, pnpm build, and the output folder, dist, then add one rule so every page survives a refresh.
The notes app has an About page at /about, but no file by that name exists in dist/. Clicking a link to it works, because the router, the part of the app that picks the page for each address, changes the address without asking the host. A refresh, or a shared link to the page (a deep link), asks the host for /about and gets its 404 page.
The fix is a fallback: any path that is not a file gets index.html, the page that starts the app, and the router reads the address. In netlify.toml, Netlify's settings file, it is one redirect rule:
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
A refresh on /about now gets index.html, and the router shows the About page.
Vercel's vercel.json takes a rewrites entry from /(.*) to /index.html. Cloudflare Pages falls back by itself unless dist/ has a top-level 404.html. GitHub Pages has no rewrite rules, so the build:pages script copies index.html to 404.html, which Pages serves for unknown paths.
pnpm preview, which serves dist/ on your laptop, always falls back, so only the real host shows a missing rule.
Before anything deploys, CI checks it: GitHub Actions, GitHub's CI, runs .github/workflows/ci.yml on every push. It installs with pnpm install --frozen-lockfile, which fails rather than change the lockfile, the file that pins every package's version. Then it lints, tests and builds, which type-checks too.
What goes wrong
Two mistakes in src/config.ts catch people out. The first reads the on-off flag showBeta, meant to be off, straight from the variable:
showBeta: import.meta.env.VITE_SHOW_BETA, // VITE_SHOW_BETA=false
The beta banner, <p v-if="config.showBeta">Try the new editor</p>, shows for everyone. v-if renders it when the value counts as true, and 'false' is a string that is not empty, so it does. Compare with the string you mean:
showBeta: import.meta.env.VITE_SHOW_BETA === 'true',
'false' === 'true' is false, so the banner stays hidden.
The second mistake is a variable without the prefix:
apiUrl: import.meta.env.API_URL, // .env says API_URL=https://api.example.com
import.meta.env.API_URL is undefined, so the app's request for notes, fetch(`${config.apiUrl}/notes`), asks your own site for undefined/notes instead of the notes server, and no notes load. With strictImportMetaEnv, pnpm type-check stops it first: Property 'API_URL' does not exist on type 'ImportMetaEnv'. Rename the variable in .env and in the code:
apiUrl: import.meta.env.VITE_API_URL, // .env says VITE_API_URL=https://api.example.com
The type check passes, and the notes load from https://api.example.com.
What to remember
- Only
VITE_variables reach the browser, and all of them are public: secrets stay on a server. vite buildwrites the values intodist/as text. A changed.envneeds a new build and a new deploy.- Give every host an
index.htmlfallback, or refreshing a deep link shows a 404. A site under a sub-path also needsbase. - Env values are strings. Compare
=== 'true'instead of trusting'false'to be false. - CI installs with
--frozen-lockfile, lints, tests and builds on every push.
In your project
Do these in a real project. Each one says how to confirm it worked.- Add
VITE_APP_NAME=Notesto.env, declare it inenv.d.tswithstrictImportMetaEnv, and show{{ config.appName }}in a heading: it reads Notes. Misspell it asVITE_APP_NAMinconfig.ts, andpnpm type-checkfails withProperty 'VITE_APP_NAM' does not exist on type 'ImportMetaEnv'. Did you mean 'VITE_APP_NAME'? - Run
pnpm build, thengrep -r "Notes" dist/assets: the name is in the JavaScript. PutVITE_APP_NAME=Notebookin.env.productionand runpnpm previewwithout building: the heading still reads Notes. Build again and preview: Notebook. - Deploy
dist/, open the About page by typing its address, and refresh: without a fallback rule, most hosts send their 404 page. Add the rule for your host, deploy again and refresh: the About page loads.