QUESTPIE
Ship

Environment

Two files declare the variables your app reads. QUESTPIE validates them when the app boots, types every read, and writes a browser module holding only the public ones.

View markdown

Your app needs a database URL and a secret. Some values also have to reach the browser. Where do you put them, and what stops a secret from riding along?

Two files

Put both beside questpie.config.ts. Codegen finds them by name. A .mts extension works too.

env.client.ts holds what the browser may see. Key each var by its plain name, with no prefix.

src/questpie/server/env.client.ts
import { clientEnv } from "questpie/env";
import { z } from "zod";

export default clientEnv({
	consumers: ["vite"],
	vars: {
		APP_URL: z.string().default("http://localhost:3000"),
	},
});

env.ts holds the server ones. Import the client file and pass it as client.

src/questpie/server/env.ts
import { env } from "questpie/env";
import { z } from "zod";

import client from "./env.client";

export default env({
	client,
	server: {
		DATABASE_URL: z.string(),
		BETTER_AUTH_SECRET: z.string().min(32),
		SMTP_PORT: z.coerce.number().default(1025),
	},
});

Any validator that implements Standard Schema works here. The examples use Zod. Then generate:

questpie generate

What that produced

SurfaceWhere it shows up
Server envThe default export of env.ts, re-exported from #questpie
app.envThe same frozen object on the app instance, typed from your file
Client module.generated/env.client.vite.ts, one per consumer you named
Server code
import { env } from "#questpie";

env.DATABASE_URL; // string
env.SMTP_PORT; // number
env.APP_URL; // string, client vars are validated on the server too
Browser code
import { env } from "#questpie/env.client.vite";

env.APP_URL; // string, inlined from import.meta.env.VITE_APP_URL

questpie.config.ts is the one exception. The generated index imports the config, so the config cannot import back. It imports ./env directly instead.

env.ts is server-only

env() throws when it detects a browser runtime. The file holds your secrets. Browser code imports the generated client module instead.

Boot fails, not the first request

env() validates the moment env.ts is evaluated. The generated index imports that file before the runtime config. So a bad value throws before the app, the adapters, auth and the database start.

Every failing variable lands in one error, listed by name. The values are never printed. Validation is also synchronous. A schema that returns a promise throws at once, naming the var.

Where each name is read

The two sides look in different places for the same value.

Declared asThe server readsThe browser reads
A server varDATABASE_URL, that name onlyNothing. It is not in the bundle
A client varAPP_URL, then VITE_APP_URLimport.meta.env.VITE_APP_URL

Unprefixed wins on the server. So a dev .env with APP_URL set is enough there. The prefixed spelling is only a fallback.

The browser has no such fallback. It reads VITE_APP_URL only.

Every build needs the prefixed name

A bundler inlines only prefixed vars. Set VITE_APP_URL wherever you build, dev included. Without it the var falls back to your schema default, or throws on import. The error names the prefixed var.

The base variables

QUESTPIE merges twelve optional vars under your server block. So env.DATABASE_URL is typed even when you never declared it, and so is env.NODE_ENV. Re-declare one in server to tighten it. A plain z.string() there makes it required.

They are optional because deployments differ. Cloud injects QUESTPIE_DB, a self-host sets DATABASE_URL, and an explicit db: { url } in runtimeConfig() needs neither. Options lists all twelve.

Neither file is required

A fresh app from create-questpie ships neither. Codegen emits nothing for env, app.env stays an empty object, and nothing validates at boot.

QUESTPIE still reads process.env on its own for the fields you leave out of runtimeConfig(). Each one has a Cloud name and a self-host fallback.

FieldTried firstThen
DatabaseQUESTPIE_DBDATABASE_URL
App URLQUESTPIE_APP_URLAPP_URL
SecretQUESTPIE_SECRETBETTER_AUTH_SECRET

Add env.ts when you want that surface typed and the failure moved to boot.

Where each topic lives

TopicPage
Every env() option, the base vars, the skip flagOptions
Consumers, prefixes, the generated browser moduleClient variables
Which file owns which setting, and what resolves aloneConfiguration
What a deployment needs on top of these varsDeploying

Next

Options covers refine for cross-field rules. It also covers the flag that lets a build run with no environment.

On this page