# Client variables (/docs/ship/environment/client)

---
title: Client variables
description: One clientEnv call declares what the browser may see. Codegen writes one module per bundler, with that bundler's prefix already spelled out.
kind: guide
package: questpie
---

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.

```ts title="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.

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

```ts title="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

```ts title="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.

<Callout type="warn" title="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.
</Callout>

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

```ts
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.

<Callout type="info" title="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.
</Callout>

## 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](/docs/ship/environment/options)** covers the server side, including
what happens when the same key appears in both `server` and `vars`.
