Options
Every option env() takes, the twelve variables QUESTPIE merges under yours, and the flag that lets a build run with no environment at all.
| Option | Type | Default |
|---|---|---|
server | Record<string, StandardSchemaV1> | Required |
client | The default export of env.client.ts | None |
refine | (env) => string | undefined | void | None |
skipValidation | boolean | Set by QUESTPIE_SKIP_ENV_VALIDATION |
emptyStringAsUndefined | boolean | true |
isServer | boolean | Detected |
Import env from questpie/env and call it as the default export of env.ts.
It validates at call time and hands back a frozen object.
server
Your server-only vars, keyed by the exact name in process.env. Each value is a
Standard Schema, and its output type becomes the property type on env.
Coercion and defaults are the schema's job, not an option here.
server: {
DATABASE_URL: z.string(),
SMTP_PORT: z.coerce.number().default(1025),
MAIL_ADAPTER: z.enum(["console", "smtp"]).optional(),
}A public prefix here is a compile error
EXPO_PUBLIC_, VITE_, NEXT_PUBLIC_ and PUBLIC_ are rejected on a
server key. Such a name reads like a secret but gets inlined into client
bundles. Public vars belong in env.client.ts.
client
The default export of env.client.ts. Its vars are merged in and validated
here as well. The server looks for the unprefixed name first, then each
consumer's prefixed spelling. Its consumers drive the client module codegen.
See Client variables.
Declare the same key in both blocks and the client wins. It is validated once, with the client schema, on the client read path. That path checks more names than the server one.
refine
A cross-field guard. It runs after every var has validated, against the finished object. Return a non-empty string and boot fails with that message. Throw and the error propagates as it is.
env({
client,
server: { BETTER_AUTH_SECRET: z.string().min(32) },
refine: (e) => {
if (e.NODE_ENV === "production" && e.BETTER_AUTH_SECRET === "dev-secret")
return "BETTER_AUTH_SECRET must not be the dev default in production";
},
});Use it for rules one schema cannot see, such as "if A then B". It does not run when validation is skipped.
skipValidation
Set it to true and no schema runs. Values are read raw and returned as they
are. A missing var comes back as undefined instead of throwing. The default
reads QUESTPIE_SKIP_ENV_VALIDATION, so you normally flip this with the
variable rather than the option.
Any non-empty value turns it on. QUESTPIE_SKIP_ENV_VALIDATION=0 skips
validation, same as =1. Unset the variable to turn it off.
emptyStringAsUndefined
On by default. An empty var is skipped during the read. It then falls through to
your schema's .default() or .optional(), rather than failing as a present
but empty string. For a client var it also falls through to the next candidate
name. Set it to false and "" reaches the schema.
isServer
Overrides the runtime check. env() throws when this is false, because browser
code must import the generated client module instead. Detection treats Node and
Bun as a server even under happy-dom or jsdom. So tests in a DOM environment
still work. You rarely set it by hand.
The base variables
These twelve are merged under your server block. Every one is optional, so
none of them can fail boot on its own. Your keys win where the names collide,
and the base version drops out of the result type.
| Variable | Type |
|---|---|
NODE_ENV | "development" | "test" | "production" optional |
QUESTPIE_DB | Optional string |
DATABASE_URL | Optional string |
QUESTPIE_APP_URL | Optional string |
APP_URL | Optional string |
QUESTPIE_SECRET | Optional string |
BETTER_AUTH_SECRET | Optional string |
QUESTPIE_STORAGE_ENDPOINT | Optional string |
QUESTPIE_STORAGE_BUCKET | Optional string |
QUESTPIE_STORAGE_REGION | Optional string |
QUESTPIE_STORAGE_ACCESS_KEY | Optional string |
QUESTPIE_STORAGE_SECRET_KEY | Optional string |
The QUESTPIE_ names are what Cloud injects. DATABASE_URL, APP_URL and
BETTER_AUTH_SECRET are the self-host spellings of the first three.
runtimeConfig() tries the QUESTPIE_ name and falls back to the other.
The storage group has no unprefixed twin. Leave storage out of
runtimeConfig() and it looks at that group. It builds an S3 adapter when the
endpoint, bucket, access key and secret key are all set. Set only the endpoint
and it warns, then falls back to local storage.
Re-declare one to tighten it. The base gives you
DATABASE_URL: string | undefined for free. This makes it required and checked:
server: {
DATABASE_URL: z.string(),
}Running with no environment
Builds, codegen and CI steps have no populated environment. They must not need
DATABASE_URL to exist. Set the flag and env() reads raw and skips every
schema:
QUESTPIE_SKIP_ENV_VALIDATION=1 bun run buildquestpie generate and questpie dev set it themselves, because both import
your code. They only set it when it is unset, so your own value wins. Other
commands leave it alone.
The flag never reaches the browser
It is read when env() runs on the server. The generated client module
validates every time it is imported. A flag cannot be read after the bundler
has already inlined the values.
Types
questpie/env exports the types when you need to name one in a shared helper.
import type { EnvOptions, QuestpieBaseEnv, ResolvedEnv } from "questpie/env";
// Most of the time you want your own env type, read off your own file.
import type envDef from "./env";
type Env = typeof envDef; // Readonly<{ DATABASE_URL: string, ... }>ResolvedEnv is the base preset minus your overrides, plus your server outputs,
plus your client outputs, all readonly. QuestpieBaseEnv is the output of the
base preset alone. ClientEnvDefinition, ClientConsumer,
ClientConsumerConfig, ClientConsumerPreset, PublicVarPrefix, InferShape
and StandardSchemaV1 come from the same entry.
Plugin config files
Every file in config/ becomes one key on the app's config bucket. A plugin claims its own by declaring a single discover pattern, with no change to framework code.
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.