Config Schema Setup
Type-safe environment variable validation using Zod with a Drizzle-like schema API. Supports server/public fields, feature flags, either-or constraints, and client-side protection.
Type-Safe Environment Variable Validation
Instead of accessing environment variables directly in code, we use a config-schema utility to validate environment variables.
Install via shadcn registry
bunx --bun shadcn@latest add https://fullstackrecipes.com/r/config-schema.jsonBasic Usage
The API uses a Drizzle-like schema pattern with server() and pub() field builders:
import { configSchema, server } from "@/lib/config/schema";
export const databaseConfig = configSchema("Database", {
url: server({ env: "DATABASE_URL" }),
});
// Type: { server: { url: string } }import { configSchema, server } from "@/lib/config/schema";
export const databaseConfig = configSchema("Database", {
url: server({ env: "DATABASE_URL" }),
});
// Type: { server: { url: string } }If DATABASE_URL is missing, you get a clear error:
Error [InvalidConfigurationError]: Configuration validation error for Database!
Did you correctly set all required environment variables in your .env* file?
- server.url (DATABASE_URL) must be defined.Error [InvalidConfigurationError]: Configuration validation error for Database!
Did you correctly set all required environment variables in your .env* file?
- server.url (DATABASE_URL) must be defined.Then import and use it:
import { databaseConfig } from "./config";
const pool = new Pool({
connectionString: databaseConfig.server.url,
});import { databaseConfig } from "./config";
const pool = new Pool({
connectionString: databaseConfig.server.url,
});Server vs Public Fields
Use server() for server-only secrets and pub() for client-accessible values:
import { configSchema, server, pub } from "@/lib/config/schema";
export const sentryConfig = configSchema(
"Sentry",
{
// Server-only - throws if accessed on client
token: server({ env: "SENTRY_AUTH_TOKEN" }),
// Client-accessible - work everywhere
dsn: pub({
env: "NEXT_PUBLIC_SENTRY_DSN",
value: process.env.NEXT_PUBLIC_SENTRY_DSN,
}),
project: pub({
env: "NEXT_PUBLIC_SENTRY_PROJECT",
value: process.env.NEXT_PUBLIC_SENTRY_PROJECT,
}),
},
{
flag: {
env: "NEXT_PUBLIC_ENABLE_SENTRY",
value: process.env.NEXT_PUBLIC_ENABLE_SENTRY,
},
},
);import { configSchema, server, pub } from "@/lib/config/schema";
export const sentryConfig = configSchema(
"Sentry",
{
// Server-only - throws if accessed on client
token: server({ env: "SENTRY_AUTH_TOKEN" }),
// Client-accessible - work everywhere
dsn: pub({
env: "NEXT_PUBLIC_SENTRY_DSN",
value: process.env.NEXT_PUBLIC_SENTRY_DSN,
}),
project: pub({
env: "NEXT_PUBLIC_SENTRY_PROJECT",
value: process.env.NEXT_PUBLIC_SENTRY_PROJECT,
}),
},
{
flag: {
env: "NEXT_PUBLIC_ENABLE_SENTRY",
value: process.env.NEXT_PUBLIC_ENABLE_SENTRY,
},
},
);Why pass value for public fields?
Next.js only inlines NEXT_PUBLIC_* environment variables when accessed statically (like process.env.NEXT_PUBLIC_DSN). Dynamic lookups like process.env[varName] don't work on the client. By passing value directly, the static references are preserved and properly inlined at build time.
Server fields can omit value since they use process.env[env] internally and are only accessed on the server.
Feature Flags
Use the flag option for features that can be enabled/disabled:
import { configSchema, server, pub } from "@/lib/config/schema";
export const sentryConfig = configSchema(
"Sentry",
{
token: server({ env: "SENTRY_AUTH_TOKEN" }),
dsn: pub({
env: "NEXT_PUBLIC_SENTRY_DSN",
value: process.env.NEXT_PUBLIC_SENTRY_DSN,
}),
project: pub({
env: "NEXT_PUBLIC_SENTRY_PROJECT",
value: process.env.NEXT_PUBLIC_SENTRY_PROJECT,
}),
org: pub({
env: "NEXT_PUBLIC_SENTRY_ORG",
value: process.env.NEXT_PUBLIC_SENTRY_ORG,
}),
},
{
flag: {
env: "NEXT_PUBLIC_ENABLE_SENTRY",
value: process.env.NEXT_PUBLIC_ENABLE_SENTRY,
},
},
);
// Type: FeatureConfig<...> (has isEnabled)import { configSchema, server, pub } from "@/lib/config/schema";
export const sentryConfig = configSchema(
"Sentry",
{
token: server({ env: "SENTRY_AUTH_TOKEN" }),
dsn: pub({
env: "NEXT_PUBLIC_SENTRY_DSN",
value: process.env.NEXT_PUBLIC_SENTRY_DSN,
}),
project: pub({
env: "NEXT_PUBLIC_SENTRY_PROJECT",
value: process.env.NEXT_PUBLIC_SENTRY_PROJECT,
}),
org: pub({
env: "NEXT_PUBLIC_SENTRY_ORG",
value: process.env.NEXT_PUBLIC_SENTRY_ORG,
}),
},
{
flag: {
env: "NEXT_PUBLIC_ENABLE_SENTRY",
value: process.env.NEXT_PUBLIC_ENABLE_SENTRY,
},
},
);
// Type: FeatureConfig<...> (has isEnabled)Enforced: If your config has public fields, the flag must use a
NEXT_PUBLIC_*variable. This is validated at definition time and throws an error if violated:Error [InvalidConfigurationError]: Configuration validation error for Sentry! Did you correctly set all required environment variables in your .env* file? - Flag "ENABLE_SENTRY" must use a NEXT_PUBLIC_* variable when config has public fields. Otherwise, isEnabled will always be false on the client.Error [InvalidConfigurationError]: Configuration validation error for Sentry! Did you correctly set all required environment variables in your .env* file? - Flag "ENABLE_SENTRY" must use a NEXT_PUBLIC_* variable when config has public fields. Otherwise, isEnabled will always be false on the client.This prevents a common bug where the flag is
undefinedon the client (since non-public env vars aren't inlined), causingisEnabledto always befalsein client code even when the feature is enabled on the server.
Behavior:
- Flag not set or falsy:
{ isEnabled: false }(no validation, no errors) - Flag is
"true","1", or"yes": validates all values, returns{ ..., isEnabled: true } - Flag truthy + missing value: throws
InvalidConfigurationError
Usage:
import { sentryConfig } from "./lib/sentry/config";
export async function register() {
if (sentryConfig.isEnabled) {
const Sentry = await import("@sentry/nextjs");
Sentry.init({
dsn: sentryConfig.public.dsn,
});
}
}import { sentryConfig } from "./lib/sentry/config";
export async function register() {
if (sentryConfig.isEnabled) {
const Sentry = await import("@sentry/nextjs");
Sentry.init({
dsn: sentryConfig.public.dsn,
});
}
}Either-Or Values
Use the oneOf constraint when a feature can be configured with alternative credentials:
import { configSchema, server, oneOf } from "@/lib/config/schema";
export const aiConfig = configSchema(
"AI",
{
oidcToken: server({ env: "VERCEL_OIDC_TOKEN" }),
gatewayApiKey: server({ env: "AI_GATEWAY_API_KEY" }),
},
{
constraints: (s) => [oneOf([s.oidcToken, s.gatewayApiKey])],
},
);
// Type: { server: { oidcToken?: string; gatewayApiKey?: string } }
// Note: No isEnabled property (no flag used)import { configSchema, server, oneOf } from "@/lib/config/schema";
export const aiConfig = configSchema(
"AI",
{
oidcToken: server({ env: "VERCEL_OIDC_TOKEN" }),
gatewayApiKey: server({ env: "AI_GATEWAY_API_KEY" }),
},
{
constraints: (s) => [oneOf([s.oidcToken, s.gatewayApiKey])],
},
);
// Type: { server: { oidcToken?: string; gatewayApiKey?: string } }
// Note: No isEnabled property (no flag used)At least one of the specified fields must have a value. Error messages include the alternatives:
Error [InvalidConfigurationError]: Configuration validation error for AI!
Did you correctly set all required environment variables in your .env* file?
- Either server.oidcToken (VERCEL_OIDC_TOKEN) or server.gatewayApiKey (AI_GATEWAY_API_KEY) must be defined.Error [InvalidConfigurationError]: Configuration validation error for AI!
Did you correctly set all required environment variables in your .env* file?
- Either server.oidcToken (VERCEL_OIDC_TOKEN) or server.gatewayApiKey (AI_GATEWAY_API_KEY) must be defined.Combining Flag and Constraints
You can use both flag and constraints together:
export const myConfig = configSchema(
"MyFeature",
{
token: server({ env: "TOKEN" }),
backupToken: server({ env: "BACKUP_TOKEN" }),
},
{
flag: { env: "ENABLE_FEATURE", value: process.env.ENABLE_FEATURE },
constraints: (s) => [oneOf([s.token, s.backupToken])],
},
);
// Type: FeatureConfig<...> (has isEnabled because flag is used)export const myConfig = configSchema(
"MyFeature",
{
token: server({ env: "TOKEN" }),
backupToken: server({ env: "BACKUP_TOKEN" }),
},
{
flag: { env: "ENABLE_FEATURE", value: process.env.ENABLE_FEATURE },
constraints: (s) => [oneOf([s.token, s.backupToken])],
},
);
// Type: FeatureConfig<...> (has isEnabled because flag is used)Optional Fields
Use optional: true for fields that are always optional:
export const authConfig = configSchema("Auth", {
secret: server({ env: "BETTER_AUTH_SECRET" }),
url: server({ env: "BETTER_AUTH_URL" }),
vercelClientId: server({ env: "VERCEL_CLIENT_ID", optional: true }),
vercelClientSecret: server({ env: "VERCEL_CLIENT_SECRET", optional: true }),
});export const authConfig = configSchema("Auth", {
secret: server({ env: "BETTER_AUTH_SECRET" }),
url: server({ env: "BETTER_AUTH_URL" }),
vercelClientId: server({ env: "VERCEL_CLIENT_ID", optional: true }),
vercelClientSecret: server({ env: "VERCEL_CLIENT_SECRET", optional: true }),
});Client-Side Protection
Server fields use a Proxy to protect values from being accessed on the client:
// On the server - everything works
sentryConfig.public.dsn; // "https://..."
sentryConfig.server.token; // "secret-token"
// On the client
sentryConfig.public.dsn; // works (public field)
sentryConfig.server.token; // throws ServerConfigClientAccessError// On the server - everything works
sentryConfig.public.dsn; // "https://..."
sentryConfig.server.token; // "secret-token"
// On the client
sentryConfig.public.dsn; // works (public field)
sentryConfig.server.token; // throws ServerConfigClientAccessErrorThis catches accidental client-side access to secrets at runtime:
Error [ServerConfigClientAccessError]: [Sentry] Attempted to access server-only config 'server.token' (SENTRY_AUTH_TOKEN) on client.
Move this value to 'public' if it needs client access, or ensure this code only runs on server.Error [ServerConfigClientAccessError]: [Sentry] Attempted to access server-only config 'server.token' (SENTRY_AUTH_TOKEN) on client.
Move this value to 'public' if it needs client access, or ensure this code only runs on server.Custom Validation
For transforms, defaults, or complex validation, pass a schema option with a Zod schema:
import { z } from "zod";
import { configSchema, server } from "@/lib/config/schema";
export const databaseConfig = configSchema("Database", {
url: server({ env: "DATABASE_URL" }),
// Transform string to number with default
poolSize: server({
env: "DATABASE_POOL_SIZE",
schema: z.coerce.number().default(10),
}),
});
// Type: { server: { url: string; poolSize: number } }import { z } from "zod";
import { configSchema, server } from "@/lib/config/schema";
export const databaseConfig = configSchema("Database", {
url: server({ env: "DATABASE_URL" }),
// Transform string to number with default
poolSize: server({
env: "DATABASE_POOL_SIZE",
schema: z.coerce.number().default(10),
}),
});
// Type: { server: { url: string; poolSize: number } }More examples:
import { z } from "zod";
import { configSchema, server } from "@/lib/config/schema";
export const config = configSchema("App", {
// Required string (default)
apiKey: server({ env: "API_KEY" }),
// Optional string
debugMode: server({
env: "DEBUG_MODE",
schema: z.string().optional(),
}),
// String with regex validation
fromEmail: server({
env: "FROM_EMAIL",
schema: z
.string()
.regex(/^.+\s<.+@.+\..+>$/, 'Must match "Name <email>" format'),
}),
// Enum with default
nodeEnv: server({
env: "NODE_ENV",
schema: z
.enum(["development", "production", "test"])
.default("development"),
}),
// Boolean
enableFeature: server({
env: "ENABLE_FEATURE",
schema: z.coerce.boolean().default(false),
}),
});import { z } from "zod";
import { configSchema, server } from "@/lib/config/schema";
export const config = configSchema("App", {
// Required string (default)
apiKey: server({ env: "API_KEY" }),
// Optional string
debugMode: server({
env: "DEBUG_MODE",
schema: z.string().optional(),
}),
// String with regex validation
fromEmail: server({
env: "FROM_EMAIL",
schema: z
.string()
.regex(/^.+\s<.+@.+\..+>$/, 'Must match "Name <email>" format'),
}),
// Enum with default
nodeEnv: server({
env: "NODE_ENV",
schema: z
.enum(["development", "production", "test"])
.default("development"),
}),
// Boolean
enableFeature: server({
env: "ENABLE_FEATURE",
schema: z.coerce.boolean().default(false),
}),
});Adding New Environment Variables
When adding a new feature that needs env vars:
- Create
src/lib/<feature>/config.ts - Use
configSchemawithserver()and/orpub()fields - Add
flagoption if the feature should be toggleable - Add
constraintsoption withoneOf()for either-or validation - Import the config in
src/instrumentation.tsfor early validation - Import and use the config within that feature
Example for adding Stripe:
import { configSchema, server, pub } from "@/lib/config/schema";
export const stripeConfig = configSchema("Stripe", {
secretKey: server({ env: "STRIPE_SECRET_KEY" }),
webhookSecret: server({ env: "STRIPE_WEBHOOK_SECRET" }),
publishableKey: pub({
env: "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
value: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
}),
});import { configSchema, server, pub } from "@/lib/config/schema";
export const stripeConfig = configSchema("Stripe", {
secretKey: server({ env: "STRIPE_SECRET_KEY" }),
webhookSecret: server({ env: "STRIPE_WEBHOOK_SECRET" }),
publishableKey: pub({
env: "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
value: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
}),
});Then use it in your Stripe client:
import Stripe from "stripe";
import { stripeConfig } from "./config";
export const stripe = new Stripe(stripeConfig.server.secretKey);import Stripe from "stripe";
import { stripeConfig } from "./config";
export const stripe = new Stripe(stripeConfig.server.secretKey);Env Workflow with Vercel
Manage environment variables across Vercel environments. Sync with Vercel CLI, handle local overrides, and load env vars in scripts.
Managing Environment Variables with Vercel and Next.js
Environments
Vercel provides three default environments: development, preview, and production (more here).
Next.js Load Order
Next.js loads environment variables in the following order, stopping once each variable is found:
process.env.env.$(NODE_ENV).local.env.local(not checked whenNODE_ENVistest).env.$(NODE_ENV).env
For example, if NODE_ENV is development and you define a variable in both .env.development and .env.local, the value in .env.local will be used.
Note: The allowed values for
NODE_ENVareproduction,development, andtest.
Note next build and next start will use the production environment variables while next dev will use the development environment variables.
Local Development
Create a .env.development file for development environment variables synced from Vercel and a .env.local file for local development overrides.
- Use
.env.developmentfor development environment variables synced from Vercel. - Override specific variables in
.env.localfor local development. - Sync
.env.productionwith Vercel to build your project locally withnext build.
Syncing with Vercel
Use the Vercel CLI to keep environment variables in sync.
We write to .env.development (not .env.local) so that local overrides in .env.local aren't deleted when pulling from Vercel.
Add these helper scripts to your package.json:
{
"scripts": {
"env:pull": "vercel env pull .env.development --environment=development",
"env:push": "vercel env push .env.development --environment=development",
"env:pull:prod": "vercel env pull .env.production --environment=production",
"env:push:prod": "vercel env push .env.production --environment=production"
}
}{
"scripts": {
"env:pull": "vercel env pull .env.development --environment=development",
"env:push": "vercel env push .env.development --environment=development",
"env:pull:prod": "vercel env pull .env.production --environment=production",
"env:push:prod": "vercel env push .env.production --environment=production"
}
}| Script | Purpose |
|---|---|
env:pull | Download environment variables from Vercel to .env.development |
env:push | Upload .env.development to Vercel |
env:pull:prod | Download environment variables from Vercel to .env.production |
env:push:prod | Upload .env.production to Vercel |
Local Overrides
Some variables differ between local and deployed environments (e.g., BETTER_AUTH_URL is http://localhost:3000 locally). Use .env.local to override specific variables from .env.development:
.env.development <- shared config from Vercel (DATABASE_URL, API keys, etc.)
.env.local <- local overrides (BETTER_AUTH_URL, local-only settings).env.development <- shared config from Vercel (DATABASE_URL, API keys, etc.)
.env.local <- local overrides (BETTER_AUTH_URL, local-only settings)Since .local files always take precedence over their non-local counterparts, your local overrides will be applied automatically.
Workflow
- Run
bun run env:pullto sync shared variables from Vercel to.env.development - Add local-only overrides to
.env.local - When adding new shared variables, update
.env.developmentand runbun run env:push
Loading Environment Variables in Scripts
Scripts and config files that run outside of Next.js (like Drizzle migrations or custom build scripts) don't have environment variables automatically loaded. Use loadEnvConfig from @next/env to load them manually:
import { $ } from "bun";
import { loadEnvConfig } from "@next/env";
loadEnvConfig(process.cwd());
await $`bunx @better-auth/cli@latest generate --config src/lib/auth/server.tsx --output src/lib/auth/schema.ts`;
await $`drizzle-kit generate`;import { $ } from "bun";
import { loadEnvConfig } from "@next/env";
loadEnvConfig(process.cwd());
await $`bunx @better-auth/cli@latest generate --config src/lib/auth/server.tsx --output src/lib/auth/schema.ts`;
await $`drizzle-kit generate`;The same pattern applies to config files like drizzle.config.ts:
// drizzle.config.ts
import { loadEnvConfig } from "@next/env";
loadEnvConfig(process.cwd());
import { defineConfig } from "drizzle-kit";
import { databaseConfig } from "./src/lib/db/config";
export default defineConfig({
schema: "./src/lib/*/schema.ts",
out: "./src/lib/db/migrations",
dialect: "postgresql",
dbCredentials: {
url: databaseConfig.server.url,
},
});// drizzle.config.ts
import { loadEnvConfig } from "@next/env";
loadEnvConfig(process.cwd());
import { defineConfig } from "drizzle-kit";
import { databaseConfig } from "./src/lib/db/config";
export default defineConfig({
schema: "./src/lib/*/schema.ts",
out: "./src/lib/db/migrations",
dialect: "postgresql",
dbCredentials: {
url: databaseConfig.server.url,
},
});Important: Call
loadEnvConfigbefore importing any modules that accessprocess.env. Environment variables must be loaded before they're read.
Environment Validation
Validate environment variables on server start and before builds. Catch missing or invalid variables early with clear error messages.
Implement Environment Validation
Validate environment variables on server start and before builds. Catch missing or invalid variables early with clear error messages.
See:
- Resource:
env-validationin Fullstack Recipes - URL: https://fullstackrecipes.com/recipes/env-validation
Validating Configs on Server Start
Some environment variables are read internally by packages rather than passed as arguments. To catch missing variables early instead of at runtime, import your configs in instrumentation.ts:
import * as Sentry from "@sentry/nextjs";
import { sentryConfig } from "./lib/sentry/config";
// Validate required configs on server start
import "./lib/ai/config";
import "./lib/db/config";
export async function register() {
// ... initialization code
}import * as Sentry from "@sentry/nextjs";
import { sentryConfig } from "./lib/sentry/config";
// Validate required configs on server start
import "./lib/ai/config";
import "./lib/db/config";
export async function register() {
// ... initialization code
}The side-effect imports trigger configSchema validation immediately when the server starts. If any required environment variable is missing, the server fails to start with a clear error rather than failing later when the code path is executed.
Validating Environment Files Pre-Build
Install via shadcn registry
bunx --bun shadcn@latest add https://fullstackrecipes.com/r/validate-env.jsonAdd the validation script to your package.json:
{
"scripts": {
"prebuild": "bun run env:validate:prod",
"env:validate": "bun run scripts/validate-env.ts --environment=development",
"env:validate:prod": "bun run scripts/validate-env.ts --environment=production"
}
}{
"scripts": {
"prebuild": "bun run env:validate:prod",
"env:validate": "bun run scripts/validate-env.ts --environment=development",
"env:validate:prod": "bun run scripts/validate-env.ts --environment=production"
}
}Use the env:validate and env:validate:prod scripts to validate all your configs (config.ts files in src/lib/*/) against your .env files.
The prebuild script (configured above) runs automatically before build, ensuring environment variables are validated before every build (locally and in CI/Vercel). If validation fails, the build stops early with a clear error.
The script:
- Loads
.envfiles using Next.js'sloadEnvConfig(respects the same load order as Next.js) - Finds all
config.tsfiles insrc/lib/*/ - Imports each config to trigger
configSchemavalidation - Reports any missing or invalid environment variables
- Warns about variables defined in
.envfiles but not used by any config
Example output with a validation error:
🔍 Environment Configuration Validator
Environment: development
Loading environment files...
✓ .env.local
✓ .env.development
Found 5 config files:
✗ src/lib/resend/config.ts
✓ src/lib/sentry/config.ts
✓ src/lib/db/config.ts
✓ src/lib/ai/config.ts
✓ src/lib/auth/config.ts
Validation Errors:
src/lib/resend/config.ts:
Configuration validation error for Resend!
Did you correctly set all required environment variables in your .env* file?
- server.fromEmail (FROM_EMAIL) must be defined.
Summary:
Configs validated: 4
Validation errors: 1
Unused env vars: 0🔍 Environment Configuration Validator
Environment: development
Loading environment files...
✓ .env.local
✓ .env.development
Found 5 config files:
✗ src/lib/resend/config.ts
✓ src/lib/sentry/config.ts
✓ src/lib/db/config.ts
✓ src/lib/ai/config.ts
✓ src/lib/auth/config.ts
Validation Errors:
src/lib/resend/config.ts:
Configuration validation error for Resend!
Did you correctly set all required environment variables in your .env* file?
- server.fromEmail (FROM_EMAIL) must be defined.
Summary:
Configs validated: 4
Validation errors: 1
Unused env vars: 0Example output with an unused variable:
🔍 Environment Configuration Validator
Environment: development
Loading environment files...
✓ .env.local
✓ .env.development
Found 5 config files:
✓ src/lib/resend/config.ts
✓ src/lib/sentry/config.ts
✓ src/lib/db/config.ts
✓ src/lib/ai/config.ts
✓ src/lib/auth/config.ts
Unused Environment Variables:
These variables are defined in .env files but not used by any config:
⚠ OLD_API_KEY
defined in: .env.local
Summary:
Configs validated: 5
Validation errors: 0
Unused env vars: 1🔍 Environment Configuration Validator
Environment: development
Loading environment files...
✓ .env.local
✓ .env.development
Found 5 config files:
✓ src/lib/resend/config.ts
✓ src/lib/sentry/config.ts
✓ src/lib/db/config.ts
✓ src/lib/ai/config.ts
✓ src/lib/auth/config.ts
Unused Environment Variables:
These variables are defined in .env files but not used by any config:
⚠ OLD_API_KEY
defined in: .env.local
Summary:
Configs validated: 5
Validation errors: 0
Unused env vars: 1The script exits with code 1 if any validation errors occur (useful for CI), but unused variables only trigger warnings without failing the build.