Skip to content
Fusion

Configuration — typed environment variables

Every app needs to read its surroundings: a database URL, an API key, the public site address. The lazy way is process.env.DATABASE_URL scattered wherever it's needed — a string | undefined that's only ever wrong at 3 a.m. in production, never at startup. This stack does the opposite: it declares one typed, validated schema for the whole environment, parses it once at boot, and hands the rest of the app a plain object whose fields have real types. That package is fusion-env, a thin wrapper over @t3-oss/env-core.

Coming from React + Django? This is your settings.py plus django-environ, collapsed into one file. createAppEnv is the moment Django reads os.environ and coerces it (env.db(), env.bool(...)) — except the coercion is a Zod schema, the result is fully typed, and a bad value fails the boot the way a missing SECRET_KEY does, not a KeyError deep in a request. The VITE_ split below is the equivalent of Django's "never leak a secret to the template" rule, enforced by the bundler instead of by discipline.

The whole package is one function

fusion-env exports exactly one thing worth calling, createAppEnv, plus the type it infers. It is short enough to read in full — this is the package:

fusion-env/src/index.ts
import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
 
export function createAppEnv(runtimeEnv: Record<string, string | undefined>) {
	return createEnv({
		server: {
			SERVER_URL: z.string().url().optional(),
			DATABASE_URL: z.string().url().optional(),
			// Optional: required when the in-app Carola chat is exercised. The
			// existing demo routes also read process.env.ANTHROPIC_API_KEY
			// directly, so leaving this optional lets the rest of the app boot
			// without it.
			ANTHROPIC_API_KEY: z.string().min(1).optional(),
			// Powers the dashboard Carola chat — `/api/ai/chat` uses the
			// OpenRouter adapter so the model is swappable via this one key.
			OPENROUTER_API_KEY: z.string().min(1).optional(),
			// Connects the web app + worker to the self-hosted Hatchet engine
			// (background jobs). Optional: when unset, uploads are NOT enqueued and
			// the app boots normally — same graceful-degradation contract as the AI
			// keys above. The worker process requires it. Generate it from the
			// Hatchet dashboard (Settings → API Tokens) or the admin CLI.
			HATCHET_CLIENT_TOKEN: z.string().min(1).optional(),
		},
 
		clientPrefix: "VITE_",
 
		client: {
			VITE_APP_TITLE: z.string().min(1).optional(),
		},
 
		runtimeEnv,
 
		emptyStringAsUndefined: true,
	});
}
 
export type AppEnv = ReturnType<typeof createAppEnv>;

Three things are happening here, and each one earns its place:

  1. server vs client. Two separate record of Zod schemas. server vars (DATABASE_URL, ANTHROPIC_API_KEY, HATCHET_CLIENT_TOKEN, …) are secrets — they exist only in the Node/Bun process. client vars must carry the clientPrefix (VITE_) and are the only ones a browser bundle is ever allowed to inline. The split is not a convention you have to remember; it's enforced (see below).
  2. Every var is a Zod schema. z.string().url() rejects a malformed URL, z.string().min(1) rejects an empty key. runtimeEnv (the raw process.env) is fed in and validated against these. A value that doesn't parse throws — at the call site, at startup.
  3. emptyStringAsUndefined: true. A shell that exports DATABASE_URL= (empty) is treated as unset, not as the string "". This is what makes .optional() mean what you'd expect, and it's why the graceful-degradation gates elsewhere in the stack (isCacheEnabled, isJobsEnabled) can simply check truthiness.

The exported AppEnv type — ReturnType<typeof createAppEnv> — is the typed object the rest of the codebase imports. Autocomplete on env. lists every declared var; env.SERVER_URL is string | undefined (because it's .optional()), and a typo like env.SERVER_RUL is a compile error, not a silent undefined.

Why a wrapper at all? fusion-env is a leaf package with no internal dependencies — the schema is the only app-specific part, so it lives in one place the whole monorepo and every package can agree on. The package itself is generic; the schema above is what a product app owns and edits.

The app declares its schema once

The example calls createAppEnv exactly once, in src/env.ts, passing the live process.env:

example/src/env.ts
import { createAppEnv } from "@tikab-interactive/fusion-env";
 
// Typed, validated runtime env (fusion-env). Server vars (DATABASE_URL, …) come
// from process.env; client vars are VITE_-prefixed. Imported for its validation
// side effect from the server-only auth module.
export const env = createAppEnv(process.env);

That's the entire integration. env is now a typed, frozen object. But the more important detail is the import, not the export — bringing this module in has a validation side effect. The three server entry points that touch the outside world all import it on their very first line, before anything reads a variable:

// example/src/lib/db.ts, src/lib/auth.ts, src/worker.ts — all start with:
import "#/env";

import "#/env" runs createAppEnv(process.env) at module-evaluation time. If DATABASE_URL is a malformed URL, the process dies here, while it's booting, with a Zod error naming the offending variable — not 200ms later inside the first request to hit the database. The web server, the worker, and the auth layer each pull it in so that whichever one starts first does the validation. This is the fail-fast contract: a misconfigured deploy refuses to start instead of limping.

Coming from Django? import "#/env" is django.setup() reading and validating settings before any view runs. A bad DATABASE_URL here is a startup crash, exactly like a malformed DATABASES dict — you find out at deploy time, in the logs, not from a user.

Server vs client — the VITE_ wall

The single most important line in the schema is clientPrefix: "VITE_". It draws a hard boundary between secrets that stay on the server and config that's safe to ship to the browser.

Loading diagram...

The wall is enforced from both sides:

  • @t3-oss/env-core rejects a misplaced declaration. Put a non-prefixed key in the client block (or a VITE_-prefixed one in server) and createEnv throws — you cannot accidentally classify a secret as client-safe.
  • Vite only inlines VITE_-prefixed vars. This is Vite's own rule: import.meta.env exposes nothing else to client code. So even if you tried to read env.DATABASE_URL from a component, the value isn't in the browser bundle to read.

The practical upshot: a server var is undefined if you reach for it in client code, and a client var is the one thing that's allowed across. VITE_APP_TITLE can render in the <title>; ANTHROPIC_API_KEY physically cannot leave the server. This is the same secret-containment instinct as the realtime and jobs browser stubs, just one layer earlier — at the config boundary rather than the module boundary.

How packages read config — they don't import env

Here's the subtlety that surprises people coming from a single-app codebase. The typed env object lives in the consumer (example/src/env.ts). The fusion-* packages do not import it — they can't, because they're published independently and don't know the app's schema. So how does fusion-cache know whether Valkey is configured?

It reads process.env directly, but only behind a named predicate:

// fusion-cache/src/index.ts
export function isCacheEnabled(): boolean {
	return Boolean(process.env.VALKEY_URL);
}

This is the deliberate division of labour, and it's worth stating plainly:

LayerReads process.env?Why
The app (example)once, via createAppEnvowns the schema; gets a typed env and fail-fast validation
A fusion-* packageyes, but only inside an is…Enabled() gatecan't know the app's schema; exposes a typed boolean instead of a raw string | undefined
App feature codealmost neverimports env (typed) or calls a package's is…Enabled() (typed)

So "no process.env sprinkled everywhere" doesn't mean the string process.env appears zero times — it means no raw, unchecked process.env.SOMETHING reads leak into feature code. The app reads it once into a typed object; each package reads its own var once, behind a boolean gate that callers branch on. A handful of process.env reads remain in the example for vars that are genuinely package-local (e.g. COLLAB_WS_URL, FLATBOX_API_URL) — config that belongs to one feature and one call site, where adding a schema entry would be ceremony for no payoff.

Coming from Django? A reusable Django app reads settings.SOMETHING and you wire it in your project's settings.py. Here a published fusion-* package can't reach into the consumer's env, so it reads process.env.ITS_VAR itself and hands you a boolean (isCacheEnabled()) — the moral equivalent of an app exposing app_settings.IS_ENABLED with a sane default rather than making you set it.

Validation is at the call site, not on a timer

One non-obvious property of @t3-oss/env-core: validation happens when createEnv is called and the values are accessed, not on some background schedule. The package's own test pins this down — a malformed URL only throws once you read the field inside the returned object:

fusion-env/test/create-app-env.test.ts
import { expect, test } from "bun:test";
 
import { createAppEnv } from "../src/index";
 
test("parses a valid runtimeEnv and exposes the values", () => {
	const env = createAppEnv({
		SERVER_URL: "https://example.com",
		VITE_APP_TITLE: "Fusion",
	});
	expect(env.SERVER_URL).toBe("https://example.com");
	expect(env.VITE_APP_TITLE).toBe("Fusion");
});
 
test("optional vars are undefined when absent", () => {
	const env = createAppEnv({});
	expect(env.SERVER_URL).toBeUndefined();
	expect(env.DATABASE_URL).toBeUndefined();
});
 
test("empty strings are treated as undefined", () => {
	const env = createAppEnv({ SERVER_URL: "" });
	expect(env.SERVER_URL).toBeUndefined();
});
 
test("rejects a malformed URL", () => {
	// Validation runs at call time; access inside the closure covers both eager
	// and lazy validation in @t3-oss/env-core.
	expect(() => createAppEnv({ SERVER_URL: "not-a-url" }).SERVER_URL).toThrow();
});

That test file is also the clearest spec of the contract: valid input round-trips (SERVER_URL comes back as the string you put in), absent optionals are undefined, an empty string becomes undefined (the emptyStringAsUndefined rule), and a malformed URL throws. Because the example imports #/env at the top of its server entry points, that "access" happens at startup — which is exactly why a bad value fails the boot rather than a request.

Adding a new variable

The flow is mechanical and type-safe end to end:

  1. Add it to the schema in fusion-env/src/index.ts — under server for a secret, under client (with the VITE_ prefix) for something the browser may see. Pick the Zod type that actually validates it: z.string().url(), z.string().min(1), z.coerce.number(), .optional() if the app must boot without it.
  2. Set it in .env.local (dev) or your deployment's env (prod). Leaving an optional one unset is fine; leaving a required one unset now fails the boot loudly.
  3. Read itenv.MY_VAR from app code (typed, autocompleted), or, if it gates an optional capability, wrap it in an isMyFeatureEnabled() predicate the way the packages do.

Optional by default is a feature. Notice that every var in the example schema is .optional(). That's the graceful-degradation contract: the AI keys, the Hatchet token, the database URL — the app is designed to boot with any optional backend absent and light that feature up only when its var appears. Make a var required only when the app genuinely cannot run without it.

Where to go next

  • Caching — the isCacheEnabled() gate this page references, in full: how a single VALKEY_URL turns the cache on and how it degrades when absent.
  • Conventions — the graceful-degradation contract that makes "every var optional" the default, shared by every fusion-* package.
  • Architecture — where fusion-env sits as a dependency-free leaf, and why the schema is the one thing the whole monorepo agrees on.
  • Deploy — supplying the real environment in production (and the air-gapped case, where most optional vars stay unset on purpose).