@oviirup/env
v1.0.3
Published
Validate environment variables with zod in Next.JS
Readme
@oviirup/env
Type-safe environment variable validation for JavaScript and TypeScript, built on Zod.
Validate process.env (or any env object) at runtime, infer types from your schemas, and block accidental access to server-only variables on the client.
This package is derived t3-env, with a few minor tweaks and a Zod-only API (t3-env accepts any Standard Schema validator). See env.t3.gg for the original project and full documentation of the upstream API.
Requirements
- Zod 4 (
zod^4) as a peer dependency - ESM (
"type": "module")
Installation
bun i @oviirup/env zodnpm i @oviirup/env zodpnpm add @oviirup/env zodQuick start
Next.js
Use @oviirup/env/next. It locks prefix to NEXT_PUBLIC_ and strict to true.
// env.ts
import { createEnv } from "@oviirup/env/next";
import { z } from "zod";
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
SECRET_KEY: z.string().min(32),
},
client: {
NEXT_PUBLIC_API_URL: z.string().url(),
NEXT_PUBLIC_APP_NAME: z.string().default("My App"),
},
vars: {
DATABASE_URL: process.env.DATABASE_URL,
SECRET_KEY: process.env.SECRET_KEY,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
},
});The Next.js helper:
- Sets
strict: truesovarsmust include every schema key - Sets
prefix: "NEXT_PUBLIC_"so client keys must use that prefix and server keys must not - Defaults missing
server/clientobjects to{}
Other frameworks
Use the core entry @oviirup/env when you need a custom prefix or loose vars.
// env.ts
import { createEnv } from "@oviirup/env";
import { z } from "zod";
export const env = createEnv({
strict: true,
prefix: "VITE_",
server: {
DATABASE_URL: z.string().url(),
API_SECRET: z.string(),
},
client: {
VITE_API_URL: z.string().url(),
VITE_APP_ENV: z.enum(["development", "production"]),
},
vars: import.meta.env,
});Usage
Import the validated object anywhere in the app. Types come from your Zod schemas.
// server.ts
import { env } from "./env";
const DB_URL = env.DATABASE_URL; // typed and validated
const SECRET = env.SECRET_KEY; // available on the server
// client.tsx
import { env } from "./env";
const API_URL = env.NEXT_PUBLIC_API_URL; // safe on the client
const DB_URL = env.DATABASE_URL; // throws: server-only accessOn the client, only client and shared keys are readable. Accessing any other key calls onBreach (or throws).
API
createEnv(options)
Parses the runtime env against your Zod schemas and returns a readonly object inferred from server, client, shared, and extends.
Entry points:
| Import | Role |
| ---------------------- | --------------------------------------------------------------- |
| @oviirup/env | Core helper. You set prefix and strict. |
| @oviirup/env/next | Next.js helper. prefix is NEXT_PUBLIC_, strict is true. |
| @oviirup/env/presets | Ready-made env objects for extends. |
Options
| Option | Default | Description |
| ------------------ | ----------------------------------- | ------------------------------------------------------------------------------------------- |
| vars | - (runtime fallback: process.env) | Values to validate. Required in the type definitions. |
| server | - | Server-only schemas. Keys must not start with prefix. |
| client | - | Client schemas. Keys must start with prefix when prefix is set. |
| shared | - | Available on both client and server. No prefix required. |
| prefix | undefined | Client key prefix. Enforced at the type level. |
| strict | false | When true, vars may only contain keys declared in your schemas. |
| isServer | isServer() | Override server/client detection. Deno is treated as server. |
| skip | false | Skip Zod validation and return the raw vars object. |
| onError | throw | Called when validation fails. |
| onBreach | throw | Called when a server-only key is read on the client. |
| extends | - | Objects merged into the result (presets or computed values). Schema data wins over presets. |
| allowEmptyString | false | When false, empty strings are treated as missing (undefined). |
Provide at least one of server or client (or both). prefix is required whenever client is set.
vars
Source object for validation. Pass process.env, import.meta.env, or an explicit map. With strict: true, TypeScript requires every schema key and rejects extras.
vars: process.env;
vars: import.meta.env;
vars: {
DATABASE_URL: process.env.DATABASE_URL;
}strict
false(default, core): extra keys onvarsare allowedtrue(Next.js helper):varsmust match schema keys exactly
prefix
When set:
- Every
clientkey must start with the prefix - No
serverkey may start with the prefix sharedkeys are exempt
prefix: "NEXT_PUBLIC_";
prefix: "VITE_";isServer
Default detection treats Node and Deno as server. Override for tests or non-browser runtimes:
isServer: true;
isServer: false;allowEmptyString
Empty strings in env files are common (SECRET=). By default they are treated as missing (undefined), so optional schemas still pass and required string schemas fail as unset.
Set this to true to keep "" as a string value:
export const env = createEnv({
server: {
OPTIONAL_TOKEN: z.string().optional(),
FLAG: z.string(),
},
vars: process.env,
allowEmptyString: true,
});With allowEmptyString: true, "" is validated as a string. That can fail z.string().min(1), URL schemas, and similar constraints, but it will satisfy z.string().
skip
Skip validation and return the raw env object. Useful in tests or generated builds where values are not available yet.
skip: process.env.SKIP_ENV_VALIDATION === "true";onError
Default: throw Invalid environment variables: <paths>.
onError: (error) => {
const issues = error.issues
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
.join("\n");
throw new Error(`Environment validation failed:\n${issues}`);
};onBreach
Default: throw Attempted to access a server-side environment variable on the client: <name>.
onBreach: (variable) => {
throw new Error(
`Cannot access server-only variable "${variable}" on the client.`,
);
};extends
Merge preset (or other) objects into the result. Later parsed schema values overwrite earlier preset keys.
import { vercel } from "@oviirup/env/presets";
import { createEnv } from "@oviirup/env/next";
import { z } from "zod";
export const env = createEnv({
extends: [vercel()],
server: {
CUSTOM_SERVER_VAR: z.string(),
},
client: {
NEXT_PUBLIC_CUSTOM_CLIENT_VAR: z.string(),
},
vars: process.env,
});Presets
Import from @oviirup/env/presets. Each function returns a validated env object suitable for extends.
| Preset | Purpose |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| vercel() | Vercel system environment variables |
| neonVercel() | Neon on Vercel (DATABASE_URL, POSTGRES_*, …) |
| supabaseVercel() | Supabase on Vercel (server + NEXT_PUBLIC_* client keys) |
| upstashRedis() | Upstash Redis (UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN) |
| vite() | Vite (BASE_URL, MODE, DEV, PROD, SSR from import.meta.env) |
import { supabaseVercel, vercel } from "@oviirup/env/presets";
import { createEnv } from "@oviirup/env/next";
export const env = createEnv({
extends: [vercel(), supabaseVercel()],
server: {},
client: {},
vars: process.env,
});Type inference
Output types follow Zod output types, including transform and coerce:
import { createEnv } from "@oviirup/env/next";
import { z } from "zod";
export const env = createEnv({
server: {
PORT: z.coerce.number(),
DATABASE_URL: z.string().url(),
},
client: {
NEXT_PUBLIC_API_URL: z.string().url(),
},
vars: process.env,
});
// env.PORT: number
// env.DATABASE_URL: string
// env.NEXT_PUBLIC_API_URL: string