Client variables
One clientEnv call declares what the browser may see. Codegen writes one module per bundler, with that bundler's prefix already spelled out.
Your browser code needs the app URL. Vite wants it called VITE_APP_URL, Next
wants NEXT_PUBLIC_APP_URL, Expo wants EXPO_PUBLIC_APP_URL. How do you
declare it once and let each bundler have its own spelling?
Declare it unprefixed
clientEnv() takes the bundlers you ship to and the vars they need. Names carry
no prefix here. The prefix is a build concern, so codegen adds it.
import { clientEnv } from "questpie/env";
import { z } from "zod";
export default clientEnv({
consumers: ["vite", "next"],
vars: {
APP_URL: z.string().default("http://localhost:3000"),
POSTHOG_KEY: z.string().optional(),
},
});The call reads nothing and validates nothing. It returns a frozen definition.
The one thing it does at runtime is resolve each consumer. So a typo in a preset
name throws when env.client.ts is evaluated.
The presets
Each preset pairs a prefix with the object that bundler rewrites.
| Preset | Prefix | Inlined from | Bundler |
|---|---|---|---|
vite | VITE_ | import.meta.env | Vite, TanStack Start |
next | NEXT_PUBLIC_ | process.env | Next.js |
expo | EXPO_PUBLIC_ | process.env | Expo, React Native, Metro |
What codegen writes
questpie generate emits one module per consumer, named after it. Two consumers
means env.client.vite.ts and env.client.next.ts.
/* oxlint-disable */
// AUTO-GENERATED by questpie codegen, DO NOT EDIT
// Regenerate with: questpie generate
import _envClient from "../env.client";
import { resolveClientEnv } from "questpie/env-client";
/** Typed client env for the vite consumer. */
export const env = resolveClientEnv(
_envClient,
{
APP_URL: import.meta.env.VITE_APP_URL,
POSTHOG_KEY: import.meta.env.VITE_POSTHOG_KEY,
},
"vite",
);
export type ClientEnv = typeof env;Look at what is in there. Every var is a literal member expression. That is the
only form a bundler inlines. Keys come out sorted. The resolver is the tiny
questpie/env-client entry, not questpie/env.
Now look at what is missing. There is no DATABASE_URL and no
BETTER_AUTH_SECRET. Server keys are not filtered out of this file. They were
never written into it.
Import it
import { env } from "#questpie/env.client.vite";
env.APP_URL; // string
env.POSTHOG_KEY; // string | undefinedThat is the working result. One declaration, a typed frozen object in the browser, and the prefix handled for you.
Validation runs on import
resolveClientEnv() checks every var when the module is first imported. It
always runs. Empty strings count as missing. A failure lists the prefixed name,
so the error points straight at what your build environment lacks.
The skip flag does not apply here
QUESTPIE_SKIP_ENV_VALIDATION is read on the server, when env() runs. A
flag cannot be read after the bundler has inlined the values, so client
validation has no off switch.
Keep the file a leaf
The generated module imports ../env.client, so env.client.ts ends up in your
browser bundle. Import only questpie/env and your validator there. No server
code, no secrets, no Node APIs.
The generated app index never imports env.client.ts itself. It imports
env.ts, which imports the client definition to validate those same vars on the
server.
A bundler without a preset
Pass an object instead of a string. It needs three fields.
clientEnv({
consumers: [
"vite",
{ name: "sveltekit", prefix: "PUBLIC_", envObject: "import.meta.env" },
],
vars: { APP_URL: z.string() },
});name becomes the file suffix, so this writes env.client.sveltekit.ts.
prefix is what the bundler inlines. envObject is either process.env or
import.meta.env, and nothing else.
PUBLIC_ is guarded but has no preset
Four prefixes are rejected on server vars. Only three of them have built-in
presets. PUBLIC_ is reserved so it cannot slip into server. Use a custom
consumer config for it.
Three details worth knowing
Codegen reads env.client.ts by importing it. If that import fails it scans the
source text instead. That fallback only recovers preset names given as strings.
A custom consumer object is lost, and you get a warning.
The generated directory is recreated on every run. Drop a consumer and its module goes with it, rather than lingering as a stale import.
Client modules are written for apps. A shareable module package does not emit them.
Next
Options covers the server side, including
what happens when the same key appears in both server and vars.