SPA — Vite plugin (#config)
Strictest SPA wiring. Each build artifact contains only its own env's config file — others are absent from the bundle entirely.
When to use this
- Pure SPA built with Vite.
- You want each build artifact to ship only its env's config — other env files must not be present in the deployment at all.
- You're OK with one build per environment, selected via
--mode <env>or aVITE_ENVenv var.
If a single multi-env build is acceptable, the simpler dynamic-import pattern is the default.
Reference example
examples/spa-vite-plugin/ — React + Vite SPA importing config through the #config alias.
Wiring
import react from "@vitejs/plugin-react";
import { envConfig } from "@vlandoss/env/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), envConfig()],
});import { type Config, schema } from "@vlandoss/env";
import * as e from "@vlandoss/env/zod";
import * as z from "zod";
export const Env = schema({
api: { BASE_URL: z.url(), TIMEOUT_MS: z.coerce.number().int().positive().default(5000) },
feature: { ANALYTICS: e.bool.default(false) },
build: { LABEL: z.string().min(1) },
});
export type EnvConfig = Config<typeof Env>;declare module "#config" {
import type { EnvConfig } from "./schema.ts";
const config: EnvConfig;
export default config;
}import config from "#config";
import { defineEnv } from "@vlandoss/env";
import { Env } from "./schema.ts";
export const env = defineEnv({ schema: Env, config });import type { EnvConfig } from "../env/schema.ts";
export default {
api: { BASE_URL: "https://api.example.com", TIMEOUT_MS: 8000 },
feature: { ANALYTICS: true },
build: { LABEL: "prod-build-marker-b71c" },
} satisfies EnvConfig;Building per env
vite build --mode development
vite build --mode production
vite build --mode stagingEach command produces a separate dist/ whose bundle contains only the matching config/*.ts. Prefer an env var over --mode? VITE_ENV=staging vite build is equivalent — see Selecting the env without --mode.
What each piece does
envConfig()plugin registersresolve.alias["#config"]pointing at[src/]config/<env>.{ts,mts,cts,js,mjs,cjs,json}(same discovery algorithm asloadConfig). The<env>isVITE_ENV(fromprocess.envor.env*) falling back to Vite'smode. It also injects__ENV_NAME__ = JSON.stringify(env)soenvName()returns the right env in the browser — see Custom modes.#config.d.tsdeclares the type of the alias for TypeScript. Vite resolves the runtime import; this just stopstscfrom complaining.defineEnv({ schema, config })runs synchronously here —configis a plain object, not a Promise.
Plugin options
envConfig({
alias: "#config", // default
cwd: process.cwd(), // default — base directory for discovery
envVar: "VITE_ENV", // default — env var that selects the env (falls back to `mode`)
});When no file matches the current env, the alias resolves to a virtual module that throws only if #config is actually imported — so tools that introspect the Vite config (Vitest's IDE-driven discovery, third-party plugins) don't trip over a config-time error.
Tradeoffs
- One build per env. Your CI/CD needs to run a build (
vite build --mode <env>, orVITE_ENV=<env> vite build) for every environment you ship, and produce a separate artifact for each. - No runtime switching. Once built, the artifact is locked to its env. If you need to swap envs without rebuilding, use the dynamic-import pattern.
SPA — dynamic import
Recommended SPA wiring. Vite splits each per-environment config into its own chunk; the browser downloads only the one matching the current mode.
SSR / SSG
Server-rendered apps where the server has access to secrets and the browser receives only a public subset, injected into HTML via <EnvScript />.