Guides

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

src/env/schema.ts
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>;
src/config/development.ts
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;
src/env/index.ts
import { defineEnv, envName } from "@vlandoss/env";
import { Env } from "./schema.ts";

export const env = await defineEnv({
  schema: Env,
  config: import(`../config/${envName()}.ts`),
});
vite.config.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() in defineEnv: Vite sees the template literal and emits one chunk per matching file under ../config/. defineEnv auto-unwraps the ESM module namespace (no manual .default).
  • await defineEnv(...): when config is a Promise, defineEnv returns Promise<Env<S>>. You can also skip the await and 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 reaches envName() in the browserreadEnv() reads window.__env, never process.env, so a pure SPA can't see NODE_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 --mode or VITE_ENV you built with. See envName() 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:

vite.config.ts
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 defineEnv from resolving until that chunk lands.

On this page