Environment Variables
Typed, safe-by-default env access. Server secrets stay on the server, client-visible config is opt-in via a naming prefix, and the build fails when a non-public variable is referenced in client code.
The Model
Pracht splits environment access into two surfaces so a secret can never accidentally ship to the browser:
| Surface | Import | Contents | Where it works |
|---|---|---|---|
serverEnv |
@pracht/core/env/server |
The full platform env | Server code only |
publicEnv |
@pracht/core (any entry) |
Only PRACHT_PUBLIC_-prefixed vars |
Everywhere |
// Server code (loaders, middleware, API routes, src/server/**):
import { serverEnv } from "@pracht/core/env/server";
export const db = connect(serverEnv.DATABASE_URL);// Anywhere — values are public and inlined into the client bundle at build time:
import { publicEnv } from "@pracht/core";
export const apiBase = publicEnv.PRACHT_PUBLIC_API_BASE;The Prefix Rule
Only variables prefixed with PRACHT_PUBLIC_ are exposed through publicEnv.
The pracht Vite plugin adds PRACHT_PUBLIC_ to Vite's
envPrefix (alongside the
default VITE_), so prefixed variables are also available directly as
import.meta.env.PRACHT_PUBLIC_* in dev and are statically inlined at build
time.
Because these values are inlined into the client bundle, never put a secret behind the prefix.
# Server-only — reachable through serverEnv, never shipped to the browser
DATABASE_URL=postgres://user:pass@host/db
SESSION_SECRET=super-secret
# Public — inlined into the client bundle, safe to expose
PRACHT_PUBLIC_APP_NAME=Acme
PRACHT_PUBLIC_API_BASE=https://api.example.comIn builds, publicEnv reads a PRACHT_PUBLIC_-only snapshot the pracht Vite
plugin injects; in dev it reads Vite's live env, and outside Vite (plain Node
entries, tests) it falls back to process.env. It is a frozen snapshot of
build-time values on the client.
Read One Key at a Time
Vite only replaces single-key import.meta.env.KEY accesses with their value.
Any other read — a bare reference, destructuring, a spread, or bracket
access — is replaced by an object literal holding every exposed variable,
including the VITE_ values Pracht does not treat as public:
// Leaks every VITE_ value into the client bundle.
const env = import.meta.env;
const { PRACHT_PUBLIC_API_BASE } = import.meta.env;
const mode = import.meta.env["MODE"];
// Fine — each access is replaced by just that value.
const apiBase = import.meta.env.PRACHT_PUBLIC_API_BASE;
const isDev = import.meta.env?.DEV;Env leak detection fails the build on whole-object reads in first-party client
code. Use publicEnv when you need to enumerate public values.
Typing Your Env Once
Declare the env shape with the same Register declaration-merging pattern used
for routes and context:
declare module "@pracht/core" {
interface Register {
env: {
DATABASE_URL: string;
SESSION_SECRET: string;
PRACHT_PUBLIC_APP_NAME: string;
PRACHT_PUBLIC_API_BASE: string;
};
}
}serverEnv is then typed as the full shape, and publicEnv automatically
narrows to the PRACHT_PUBLIC_-prefixed subset — referencing
publicEnv.DATABASE_URL is a type error. Without a registration both fall back
to Record<string, string | undefined>.
Per-Adapter Behavior of serverEnv
- Node (
@pracht/adapter-node) — resolves toprocess.env. Available at module top level. - Netlify (
@pracht/adapter-netlify) — resolves toprocess.env, populated by the Netlify Functions runtime. Available at module top level. - Vercel (
@pracht/adapter-vercel) — resolves toprocess.env, which the Vercel runtime populates in both Node and edge functions. Available at module top level. - Cloudflare (
@pracht/adapter-cloudflare) — Workers have no ambient env; bindings arrive per request. The adapter installs the workerenvbindings when a request enters the fetch handler, soserverEnvworks inside loaders, middleware, and API routes but not at module top level (it throws before the first request with a message explaining this). Non-string bindings (KV, D1, …) are reachable throughserverEnvtoo, butcontext.envremains the canonical way to access bindings.
Custom setups can call setServerEnv(env) (exported from
@pracht/core/env/server and @pracht/core/server) to install another source.
Local Environment Files
pracht dev loads .env files into process.env for process-based runtimes;
real environment variables win. For development mode, precedence is
.env.development.local, .env.development, .env.local, then .env.
Cloudflare Worker bindings are different: Wrangler owns them. For
pracht preview, put local-only values such as
PRACHT_CONFIRMATION_SECRET and PRACHT_REVALIDATE_TOKEN in a gitignored
.dev.vars file. Prefixing the host command with those variables does not
automatically create Worker bindings. Use wrangler secret for production.
pracht build does not copy unprefixed .env values into process.env, and
pracht verify / pracht doctor do not use those files to satisfy deployment
secret checks. This keeps missing server-only configuration visible.
PRACHT_PUBLIC_ and VITE_ values remain different: Vite intentionally loads
them from .env at build time and compiles them into the client bundle.
Client-Leak Detection
During pracht build the plugin scans every client chunk for references to
process.env.X / import.meta.env.X (including ["X"] bracket access) where
X is not PRACHT_PUBLIC_- or VITE_-prefixed and not a Vite built-in
(MODE, DEV, PROD, SSR, BASE_URL, NODE_ENV). A hit fails the build
naming the variable, the chunk, and the likely source module:
[pracht] Environment variable leak detected in the client bundle:
- process.env.DATABASE_URL in chunk "assets/dashboard-a1b2c3.js" (likely from "/src/routes/dashboard.tsx")
Only PRACHT_PUBLIC_- or VITE_-prefixed variables may be referenced in client code
(prefer publicEnv from "@pracht/core" for typed PRACHT_PUBLIC_ values).Importing @pracht/core/env/server from client code also fails the build
immediately. Route files may import it freely for loader / headers /
getStaticPaths — the client transform strips those exports and the import
along with them.
pracht verify (and pracht doctor) read the build-time env-safety report
emitted to dist/client/_pracht/env-safety.json and re-run the leak scan
against an existing dist/client output.
Escape Hatch
Intentional, known-safe references can be allowlisted, or the check disabled entirely in your Vite config:
import { defineConfig } from "vite";
import { pracht } from "@pracht/vite-plugin";
export default defineConfig({
plugins: [
pracht({
envSafety: { allow: ["SENTRY_RELEASE"] },
// envSafety: false, // disable the check entirely (not recommended)
}),
],
});Limits
The check detects references, not values. A secret returned from a loader
still reaches the client through hydration state, and a value inlined via a
custom Vite define is invisible to the scan. Keep secrets out of loader
return data, and use the audit-secrets skill for dataflow-level review of what
your loaders send to the browser.
Verification and build checks
pracht verify and pracht build recognize the same environment access syntax,
including optional chaining and bracket access. Comments, string contents, and
regular-expression literals do not count as reads; expressions inside template
strings do. Verification checks source files, while the build also checks the
emitted client bundles.