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.
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.
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.
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 generateWhat that produced
| Surface | Where it shows up |
|---|---|
Server env | The default export of env.ts, re-exported from #questpie |
app.env | The same frozen object on the app instance, typed from your file |
| Client module | .generated/env.client.vite.ts, one per consumer you named |
import { env } from "#questpie";
env.DATABASE_URL; // string
env.SMTP_PORT; // number
env.APP_URL; // string, client vars are validated on the server tooimport { env } from "#questpie/env.client.vite";
env.APP_URL; // string, inlined from import.meta.env.VITE_APP_URLquestpie.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 as | The server reads | The browser reads |
|---|---|---|
A server var | DATABASE_URL, that name only | Nothing. It is not in the bundle |
| A client var | APP_URL, then VITE_APP_URL | import.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.
| Field | Tried first | Then |
|---|---|---|
| Database | QUESTPIE_DB | DATABASE_URL |
| App URL | QUESTPIE_APP_URL | APP_URL |
| Secret | QUESTPIE_SECRET | BETTER_AUTH_SECRET |
Add env.ts when you want that surface typed and the failure moved to boot.
Where each topic lives
| Topic | Page |
|---|---|
Every env() option, the base vars, the skip flag | Options |
| Consumers, prefixes, the generated browser module | Client variables |
| Which file owns which setting, and what resolves alone | Configuration |
| What a deployment needs on top of these vars | Deploying |
Next
Options covers refine for cross-field
rules. It also covers the flag that lets a build run with no environment.