Configuration After the Build

import.meta.env is substituted at build time, so per-environment settings belong in a config file the app fetches at startup.

SyntaxcreateApp(App).provide(configKey, await loadConfig()).mount('#app')

Vite does not read environment variables at runtime. Its documentation is explicit that import.meta.env constants are "statically replaced at build time". By the time the app is running there is no import.meta.env.VITE_API_URL left to read; the literal string it held is sitting in the bundle. Setting a different value on the server and restarting the container changes nothing.

Why that matters

It means a build is pinned to one environment. If staging and production differ only in an API address, baking it in forces two builds, and then the artifact you tested is not the artifact you ship. The rule worth holding to is one image, many environments: the build is identical everywhere, and each environment supplies its own settings.

Serving the settings instead

Put a config.json in public/. Vite copies that directory to the output root as-is and never hashes it, so the deployed file can be replaced per environment: a mounted ConfigMap, a per-environment image layer, a line in the release script. Fetch it before mount(), then hand the result to the app with provide() so any component can inject it.

What trips people up

The file is public. Anyone can open it, so it holds addresses and feature switches and never a secret. That is the same constraint Vite states for VITE_ variables, which should not contain API keys. Serve it with no-cache, or a cached copy will outlive the environment it described. And decide what happens when the fetch fails, because the default is a blank page with one console error.

Example

Example · vue
<script setup lang="ts">
import { computed, inject } from 'vue'
import { configKey } from '../config'

// inject(key, default) returns the value, not value-or-undefined, so there is
// no non-null assertion here and the component still works in a unit test.
// The default has to be a complete RuntimeConfig, featureFlags included.
const config = inject(configKey, {
  apiBaseUrl: '/api',
  environment: 'local',
  featureFlags: {},
})

const isProduction = computed(() => config.environment === 'production')
</script>

<template>
  <dl class="env-panel">
    <dt>Environment</dt>
    <dd>{{ config.environment }}</dd>
    <dt>API</dt>
    <dd>
      <code>{{ config.apiBaseUrl }}</code>
    </dd>
  </dl>
  <p v-if="!isProduction" class="env-warning" role="note">
    This is not production data.
  </p>
</template>

<style scoped>
.env-panel {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.25rem 1rem;
  font-size: 0.875rem;
}

.env-warning {
  color: #b45309;
}
</style>

When to use it

  • Promoting one container image from staging to production with only the API address changing.
  • Letting an operator flip a feature switch by editing a ConfigMap instead of rebuilding.
  • Pointing a review deployment at a per-branch backend without a separate build per branch.

More examples

src/config.ts

One module owning the shape, the key and the fetch. The `Symbol() as InjectionKey<T>` cast is the pattern from the Vue TypeScript guide; it is what gives the component above its types without an import of the interface. `import.meta.env.BASE_URL` is the one build-time constant that is still correct to use, because it describes where the app is mounted rather than which environment it is in, and that genuinely is fixed at build time.

Example · ts
// src/config.ts
import type { InjectionKey } from 'vue'

export interface RuntimeConfig {
  apiBaseUrl: string
  environment: 'local' | 'staging' | 'production'
  featureFlags: Record<string, boolean>
}

// A Symbol cast to InjectionKey<T> is how Vue documents typed provide/inject:
// provide() then rejects the wrong shape and inject() knows the right one.
export const configKey = Symbol('runtime-config') as InjectionKey<RuntimeConfig>

export async function loadConfig(): Promise<RuntimeConfig> {
  const res = await fetch(`${import.meta.env.BASE_URL}config.json`, {
    cache: 'no-store',
  })
  if (!res.ok) {
    throw new Error(`config.json returned ${res.status}`)
  }
  return (await res.json()) as RuntimeConfig
}

src/main.ts

The bootstrap. `provide()` on the app instance makes the config injectable from every component in the tree, which is cleaner than a module-level singleton because a test can mount a component with its own config and no global state to reset. The `catch` branch is the part people leave out: without it, a 404 on config.json gives a white screen, and the only clue is in the console.

Example · ts
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import ConfigError from './ConfigError.vue'
import router from './router'
import { configKey, loadConfig } from './config'

// Top-level await. Vite 8 targets browsers that support it, and it keeps the
// app from mounting with a half-configured API client.
try {
  const config = await loadConfig()
  createApp(App).provide(configKey, config).use(router).mount('#app')
} catch (error) {
  console.error('Could not load runtime config', error)
  createApp(ConfigError).mount('#app')
}

The deployed file, and its cache rule

The version in `public/` is the local default and is what a developer gets from a fresh clone. In staging and production the deployed copy at the output root is overwritten: a Kubernetes ConfigMap mounted over that one path, or a line in the release script. Because Vite never hashes files from `public/`, the path is stable enough to mount onto, which is precisely why this works and why the no-cache header is not optional.

Example · text
# public/config.json -- the committed default, used for local dev
{
  "apiBaseUrl": "http://localhost:8080/api",
  "environment": "local",
  "featureFlags": { "newInvoiceEditor": true }
}

# docker/nginx.conf -- the file is replaced per environment, so never cache it
location = /config.json {
  add_header Cache-Control "no-cache" always;
  try_files $uri =404;
}

Discussion

  • Be the first to comment on this lesson.