QUESTPIE
ShipEnvironment

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.

View markdown

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.

src/questpie/server/env.client.ts
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.

PresetPrefixInlined fromBundler
viteVITE_import.meta.envVite, TanStack Start
nextNEXT_PUBLIC_process.envNext.js
expoEXPO_PUBLIC_process.envExpo, 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.

src/questpie/server/.generated/env.client.vite.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

src/lib/client.ts
import { env } from "#questpie/env.client.vite";

env.APP_URL; // string
env.POSTHOG_KEY; // string | undefined

That 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.

On this page