vite-runtime-config-kit
v0.1.0
Published
Runtime configuration for Vite apps - one build, many environments. Fetch, validate (Standard Schema) and consume a runtime-config.json at container start, with a React provider/hook. Zero runtime dependencies.
Maintainers
Readme
vite-runtime-config-kit
Runtime configuration for Vite apps: one build, many environments. Fetch,
validate and consume a runtime-config.json at container start, with a small
React provider/hook on top. Zero runtime dependencies.
- Framework-agnostic core (
loadRuntimeConfig) with nowindow/documentusage, so it works in SSR and Node tests. - Optional React entry (
vite-runtime-config-kit/react) with a typed provider/hook. - Validation through Standard Schema v1
(zod >= 3.24, valibot, arktype, ...) or a plain
validatefunction - no validation library is a dependency of this package. - Timeout, retries with backoff, and a single typed error (
RuntimeConfigError).
The problem
Vite inlines import.meta.env.* at build time. A single static image
therefore cannot easily receive different values when the container starts,
which breaks the "build once, deploy many" model used with Docker, Kubernetes,
Cloud Run, and similar. This is a long-standing, recurring request:
- https://github.com/vitejs/vite/issues/10059
- https://github.com/vitejs/vite/issues/16069
- https://github.com/vitejs/vite/issues/17848
- https://github.com/vitejs/vite/issues/8021
- https://github.com/vitejs/vite/discussions/6387
- https://github.com/vitejs/vite/discussions/3855
- https://github.com/vitejs/vite/discussions/19200
The robust workaround is to serve a small JSON (or JS) file that is generated at container start and fetched by the app before it renders. This package implements the fetch + validate + consume side of that pattern.
Install
npm install vite-runtime-config-kitreact is an optional peer dependency, only needed if you import
vite-runtime-config-kit/react. The core has no dependencies at all.
Quick start
The config must be loaded before React renders. Do it before createRoot
so the whole tree can rely on it being present - there is intentionally no
fetching inside the provider.
// main.tsx
import { createRoot } from "react-dom/client";
import { z } from "zod";
import { loadRuntimeConfig } from "vite-runtime-config-kit";
import { RuntimeConfigProvider } from "vite-runtime-config-kit/react";
import { App } from "./App";
const schema = z.object({
API_URL: z.string().url(),
ENVIRONMENT: z.enum(["dev", "test", "prod"]),
});
const config = await loadRuntimeConfig({
source: "/runtime-config.json",
schema,
});
createRoot(document.getElementById("root")!).render(
<RuntimeConfigProvider config={config}>
<App />
</RuntimeConfigProvider>,
);// somewhere in a component
import { useRuntimeConfig } from "vite-runtime-config-kit/react";
function ApiStatus() {
const { API_URL } = useRuntimeConfig<AppConfig>();
return <span>{API_URL}</span>;
}Typed pair without repeating the generic
createRuntimeConfig<T>() returns a Provider / useConfig pair already bound
to your config type, so consumers do not pass a generic every time:
// config.ts
import { createRuntimeConfig } from "vite-runtime-config-kit/react";
export interface AppConfig {
API_URL: string;
ENVIRONMENT: "dev" | "test" | "prod";
}
export const { Provider, useConfig } = createRuntimeConfig<AppConfig>();const { API_URL } = useConfig(); // typed as AppConfig, no generic neededAPI
loadRuntimeConfig<T>(options?): Promise<T>
| Option | Default | Description |
| -------------- | ------------------------ | --------------------------------------------------------------------------- |
| source | "/runtime-config.json" | URL to fetch the JSON from. |
| schema | - | A Standard Schema v1 schema (zod/valibot/arktype). T is inferred from it. |
| validate | - | A plain (data) => T function (used when no schema). Throw to reject. |
| fetchFn | globalThis.fetch | Custom fetch (tests, non-global fetch). |
| timeoutMs | 10000 | Abort the request after this many ms (AbortController). |
| retries | 2 | Retries after a retryable failure (see below). |
| retryDelayMs | 500 | Delay between retries. |
| cache | "no-store" | Forwarded to fetch - avoids stale CDN/browser copies. |
Retries apply only to transient failures: network errors, timeouts, and HTTP 5xx. A 4xx response, a JSON parse error, and a validation failure are not retried. Validation runs once, after the fetch/retry loop succeeds.
RuntimeConfigError
Every failure is thrown as a single error class with a kind discriminator:
| kind | Meaning | Extra fields |
| ------------ | ------------------------------------------------ | ------------- |
| network | fetch rejected (offline, DNS, CORS). | cause |
| timeout | Aborted after timeoutMs. | cause |
| http | Non-2xx response. | status |
| parse | Body was not valid JSON. | cause |
| validation | JSON fetched, but failed the schema/validate fn. | issues |
All instances also carry url. This makes a readable error screen easy:
try {
const config = await loadRuntimeConfig({ schema });
// ...
} catch (e) {
if (e instanceof RuntimeConfigError) {
// e.kind === "validation" -> show e.issues; otherwise "config unavailable".
}
}React entry (vite-runtime-config-kit/react)
RuntimeConfigProvider({ config, children })- provides an already-loaded config. No fetching.useRuntimeConfig<T>()- reads the config; throws a readable error when used outside a provider.createRuntimeConfig<T>()- returns a typed{ Provider, useConfig }pair with its own React context.
Why not env replacement at container start
A tempting alternative is to sed-replace placeholder strings inside the built
JavaScript bundle at container start. Avoid this - it is brittle:
placeholders can be renamed or split by minification, source maps drift, and a
single missed token silently ships the wrong value. The stable model is to keep
the bundle immutable and serve configuration as a separate file the app fetches:
<script src="/runtime-config.js"></script>or
await fetch("/runtime-config.json");This package uses the fetch variant.
Docker / nginx example
Generate runtime-config.json from environment variables in the container
entrypoint, then start the server. (A dedicated CLI to generate this file with
schema validation is planned for 0.2 - the snippet below is plain sh.)
#!/bin/sh
# docker-entrypoint.sh
set -eu
cat > /usr/share/nginx/html/runtime-config.json <<EOF
{
"API_URL": "${API_URL}",
"ENVIRONMENT": "${ENVIRONMENT}"
}
EOF
exec nginx -g "daemon off;"# Dockerfile (excerpt)
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]Serve runtime-config.json with Cache-Control: no-store (and the app fetches
it with cache: "no-store" by default) so a new deployment is picked up
immediately rather than served stale from a CDN or the browser cache.
Limitations
- Runtime config is public: it ships to the browser. Never put secrets (API keys, passwords, private keys) in it.
- The config must be loaded before React renders (bootstrap before
createRoot); the provider does not fetch. - Stale caches are a real risk - keep
no-storeon both the response and the fetch unless you add explicit cache busting. - 0.1 covers the runtime side only. A config-generating CLI (0.2) and a Vite plugin (0.3) are planned.
License
MIT
