Why import.meta.env is undefined after a Vite build, and how to fix it.
Vite copies each import.meta.env.VITE_* value into your JavaScript when vite build runs. If the variable has no VITE_ prefix, lives only in an uncommitted .env file, or was set after the build, the bundle holds undefined. Rename it, set it where the build runs, then rebuild. Values that must change without a rebuild need runtime config.
Facts checked
Find your symptom
Almost every case is one of these. The first three apply on any host; the last two are specific to how Canner scopes variables.
| What you see | Why | Fix |
|---|---|---|
import.meta.env.API_URL is undefined everywhere, even with vite dev † † | Vite only exposes variables that start with VITE_ (or your envPrefix) to browser code | Rename it VITE_API_URL. Never set envPrefix to an empty string: Vite refuses it because it would expose every variable |
| Works locally, undefined after deploy † | The value is in .env.local or another file your .gitignore keeps out, so the build server never had it | Set it in your host’s environment variables. Real environment variables also win over any .env file |
| You changed the value in the dashboard and the site still shows the old one † | vite build replaced the expression with a string. The running site no longer reads anything | Rebuild. On Canner, redeploy |
| Undefined only on pull-request previews (Canner) | The variable is scoped production, and previews only get all and preview | Scope it all, or add a preview copy with the staging value |
| Undefined in every deploy even though it is set (Canner) | It is scoped runtime only, which never reaches a build | Change the scope to production or all, then redeploy |
† Competitor details come from each vendor’s own published pages on the dates shown; prices and plans change, so check the vendor before you decide. `import.meta.env.API_URL` is undefined everywhere, even with `vite dev` (2026-10-29) · `import.meta.env.API_URL` is undefined everywhere, even with `vite dev` (2026-10-29) · Works locally, undefined after deploy (2026-10-29) · You changed the value in the dashboard and the site still shows the old one (2026-10-29)
What vite build does with the value
Vite’s docs say the constants are “statically replaced at build time”. There is no import.meta.env object in the browser to look things up in later. The build reads the value once and writes it into the output as text:
vite buildruns inproductionmode, so it loads.env,.env.local,.env.productionand.env.production.local. Variables already set in the environment when Vite starts take priority over all of them.- The same replacement happens in
index.html:%VITE_API_URL%becomes the value at build time. - Anything with the
VITE_prefix ends up in public JavaScript. Vite’s own docs say these variables should not hold API keys. Keep secrets on a server.
// src/api.ts, what you write
const base = import.meta.env.VITE_API_URL;
// dist/assets/index-<hash>.js, what ships after
// VITE_API_URL=https://api.example.ca vite build
const base = "https://api.example.ca";
// Check what a build actually contains:
grep -r "api.example.ca" dist/assetsSetting Vite variables on Canner
Canner runs your build script with the project’s variables in the environment, so Vite picks them up like any other environment variable. The build log confirms it with a line such as [canner] wrote 6 env var(s) to .env.production (build phase). Set values in the project’s Env Vars tab, not in a committed .env.production. The Vite docs page covers the rest of the deploy.
A changed value applies on the next deploy, because the old bundle already contains the old string. Two less obvious points from the environment variables docs:
- Sensitive hides a value in the dashboard. It does not hide it from visitors: a sensitive
VITE_value is still written into the public bundle. - Runtime only keeps a secret away from the build, which is exactly why a
VITE_variable with that scope is always undefined.
Expose a build value without the prefix
Vite’s define option replaces a global name with a JSON value during the build. Use it for values you choose on purpose, such as a preview banner or a version string in your error reports:
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// CANNER_ENV is "production" or "preview" during a Canner build.
// It has no VITE_ prefix, so pass it through define, by name.
export default defineConfig({
plugins: [react()],
define: {
__APP_ENV__: JSON.stringify(process.env.CANNER_ENV ?? "local"),
__COMMIT__: JSON.stringify(process.env.CANNER_COMMIT_SHA ?? "dev"),
},
});One build for many environments
Build-time values mean one build per environment. If you want to promote the exact dist/ you tested in staging into production, the values can’t be baked in. They have to be fetched or injected when the page loads.
| Approach | Where the value lives | Changing it needs | Watch out for |
|---|---|---|---|
Build per environment (VITE_*) † | Inside the JavaScript bundle | A rebuild | Secrets are visible to anyone |
Fetched config.json | A file next to index.html | Replacing one file | A missing file returns index.html on SPA hosts, and res.json() fails with Unexpected token '<' |
window.__ENV written by a server | The server’s environment at startup | A restart | You now run a server; escape < in the JSON |
† Competitor details come from each vendor’s own published pages on the dates shown; prices and plans change, so check the vendor before you decide. Build per environment (`VITE_*`) (2026-10-29)
Pattern 1: fetch config.json before the app starts
Your deploy step writes a different config.json per environment and leaves the JavaScript alone. Check the content type: a single-page-app host answers any unknown path with index.html and a 200, so a missing config looks like a successful request.
// public/config.json is copied to dist/ untouched
{ "apiUrl": "https://api.staging.example.ca" }
// src/main.ts
const res = await fetch("/config.json", { cache: "no-store" });
const type = res.headers.get("content-type") ?? "";
if (!res.ok || !type.includes("json")) {
// An SPA host answers a missing file with index.html
throw new Error("config.json is missing from this deploy");
}
export const config: { apiUrl: string } = await res.json();Pattern 2: inject window.__ENV from a server
Put <!--app-env--> in the <head> of index.html, and a small server fills it from its own environment when it starts. Only public values go in: the script tag is readable by every visitor.
// server.mjs: serves dist/ and writes public values into index.html
import express from "express";
import { readFileSync } from "node:fs";
const env = { apiUrl: process.env.API_URL }; // public values only
const html = readFileSync("dist/index.html", "utf8").replace(
"<!--app-env-->",
"<script>window.__ENV=" + JSON.stringify(env).replace(/</g, "\\u003c") + "</script>",
);
const app = express();
app.use(express.static("dist", { index: false }));
app.use((_req, res) => res.type("html").send(html));
app.listen(process.env.PORT || 3000);Which one to use on Canner
On Canner every variable change applies on the next deploy, and every deploy runs the build. Runtime config doesn’t save you a build here. It is worth it when the same artifact also ships somewhere else, or when your app already has a Node server.
The simpler path is one Canner project per environment: a staging project and a production project, which may be connected to the same repository, each with its own VITE_ values. Starter includes unlimited projects. Note that a Starter project sleeps after three hours idle, which is usually fine for staging.
If you go with Pattern 2, Canner detects a project that ships its own Node server alongside the built client and runs the server instead of serving dist/ statically. More framework guides are in hosting by framework.
Questions
Why does import.meta.env work in dev but not in production?
In dev, Vite reads your .env files on your machine, including .env.local. In production the value has to exist where vite build runs. A gitignored file never gets there, and a variable without the VITE_ prefix is never exposed to browser code at all.
Can I change a Vite environment variable without rebuilding?
Not a VITE_ one: the value is written into the JavaScript during the build. Move the value to a fetched config.json or a server-injected window.__ENV if it has to change without a build.
Is it safe to put an API key in a VITE_ variable?
No. Everything with the VITE_ prefix ends up in public JavaScript that anyone can read. Call the API from a server you control and keep the key there.
Do I need to redeploy on Canner after changing a VITE_ variable?
Yes. Variable changes apply on the next deploy, and the build that deploy runs is what writes the new value into the bundle. See environment variables.
Deploy your Vite app from Montréal.
Set VITE_ variables in the dashboard, push, and Canner builds and serves the bundle.