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.
When to use this
- Pure SPA built with Vite (no SSR).
- One build artifact that serves every environment is acceptable — the browser only downloads the chunk it needs.
- You want the simplest wiring.
If you need each build artifact to contain only its env's config (others not present at all), use the Vite plugin pattern instead.
Reference example
examples/spa-vite-dynamic/ — React + Vite SPA loading config via dynamic import().
Wiring
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>;import type { EnvConfig } from "../env/schema.ts";
export default {
api: { BASE_URL: "http://localhost:3001/dev-api", TIMEOUT_MS: 2000 },
feature: { ANALYTICS: false },
build: { LABEL: "spa-dynamic-dev-7c21" },
} satisfies EnvConfig;import { defineEnv, envName } from "@vlandoss/env";
import { Env } from "./schema.ts";
export const env = await defineEnv({
schema: Env,
config: import(`../config/${envName()}.ts`),
});import react from "@vitejs/plugin-react";
import { envConfig } from "@vlandoss/env/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), envConfig()],
});What each piece does
- Dynamic
import()indefineEnv: Vite sees the template literal and emits one chunk per matching file under../config/.defineEnvauto-unwraps the ESM module namespace (no manual.default). await defineEnv(...): whenconfigis a Promise,defineEnvreturnsPromise<Env<S>>. You can also skip theawaitand let consumers handle the Promise.envConfig()plugin: in this pattern you don't import#config, so why bring the plugin? Because it also injects__ENV_NAME__at build time, and that constant is the only way the built env name reachesenvName()in the browser —readEnv()readswindow.__env, neverprocess.env, so a pure SPA can't seeNODE_ENV/VITE_ENV. Without the plugin,envName()falls back to"development"after a build — silently shipping your dev config to every environment — no matter what--modeorVITE_ENVyou built with. SeeenvName()and Custom modes.
Hardening chunk filenames
By default, Vite names code-split chunks predictably (assets/development.js, assets/staging.js, …). If you don't want a curious user to fetch other envs by guessing filenames, hash-only the names:
export default defineConfig({
plugins: [react(), envConfig()],
build: {
rollupOptions: {
output: { chunkFileNames: "assets/[hash].js" },
},
},
});Tradeoffs
- All env configs are shipped in the deployment as separate chunks. The browser only downloads one, but the others exist as static assets. Use the Vite plugin pattern if that's not acceptable.
- The browser does an extra request for the config chunk after the main JS — usually negligible, but it does block
defineEnvfrom resolving until that chunk lands.
Filesystem (loadConfig)
Wire @vlandoss/env in a long-running server. Schema, per-environment config files on disk, and a typed env loaded at boot via loadConfig.
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.