Guides

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 a VITE_ENV env 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

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()],
});
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/env/config.d.ts
declare module "#config" {
  import type { EnvConfig } from "./schema.ts";
  const config: EnvConfig;
  export default config;
}
src/env/index.ts
import config from "#config";
import { defineEnv } from "@vlandoss/env";
import { Env } from "./schema.ts";

export const env = defineEnv({ schema: Env, config });
src/config/production.ts
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

terminal
vite build --mode development
vite build --mode production
vite build --mode staging

Each 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 registers resolve.alias["#config"] pointing at [src/]config/<env>.{ts,mts,cts,js,mjs,cjs,json} (same discovery algorithm as loadConfig). The <env> is VITE_ENV (from process.env or .env*) falling back to Vite's mode. It also injects __ENV_NAME__ = JSON.stringify(env) so envName() returns the right env in the browser — see Custom modes.
  • #config.d.ts declares the type of the alias for TypeScript. Vite resolves the runtime import; this just stops tsc from complaining.
  • defineEnv({ schema, config }) runs synchronously here — config is 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>, or VITE_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.

On this page