Type-Safe Environment Configuration with better-env
Use better-env config modules for type-safe server/public env access, feature flags, and either-or credential constraints.
Type-Safe Environment Configuration with better-env
Use better-env/config-schema for typed environment configuration instead of maintaining a custom local env schema utility.
Install better-env
bun add better-env
bunx skills add neondatabase/better-env -a cursor -a codex -ybun add better-env
bunx skills add neondatabase/better-env -a cursor -a codex -yDefine feature-level config modules
Create config modules in src/lib/*/config.ts.
import { configSchema, server } from "better-env/config-schema";
export const databaseConfig = configSchema("Database", {
url: server({ env: "DATABASE_URL" }),
});import { configSchema, server } from "better-env/config-schema";
export const databaseConfig = configSchema("Database", {
url: server({ env: "DATABASE_URL" }),
});Then consume values from your module instead of reading process.env directly:
import { Pool } from "pg";
import { databaseConfig } from "./config";
export const pool = new Pool({
connectionString: databaseConfig.server.url,
});import { Pool } from "pg";
import { databaseConfig } from "./config";
export const pool = new Pool({
connectionString: databaseConfig.server.url,
});Public values and feature flags
Use pub() for client-safe values and flag when a feature is optional.
import { configSchema, pub, server } from "better-env/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,
}),
},
{
flag: {
env: "NEXT_PUBLIC_ENABLE_SENTRY",
value: process.env.NEXT_PUBLIC_ENABLE_SENTRY,
},
},
);import { configSchema, pub, server } from "better-env/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,
}),
},
{
flag: {
env: "NEXT_PUBLIC_ENABLE_SENTRY",
value: process.env.NEXT_PUBLIC_ENABLE_SENTRY,
},
},
);Either-or credentials
Use oneOf when at least one credential must be configured.
import { configSchema, oneOf, server } from "better-env/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])],
},
);import { configSchema, oneOf, server } from "better-env/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])],
},
);Optional fields and schema validation
You can keep optional env vars and custom validation with Zod.
import { z } from "zod";
import { configSchema, server } from "better-env/config-schema";
export const resendConfig = configSchema("Resend", {
apiKey: server({ env: "RESEND_API_KEY" }),
fromEmail: server({
env: "RESEND_FROM_EMAIL",
schema: z
.string()
.regex(
/^.+\s<.+@.+\..+>$/,
'Must match "Name <email@domain.com>" format.',
),
}),
});import { z } from "zod";
import { configSchema, server } from "better-env/config-schema";
export const resendConfig = configSchema("Resend", {
apiKey: server({ env: "RESEND_API_KEY" }),
fromEmail: server({
env: "RESEND_FROM_EMAIL",
schema: z
.string()
.regex(
/^.+\s<.+@.+\..+>$/,
'Must match "Name <email@domain.com>" format.',
),
}),
});Environment Variable Management with better-env + Vercel
Sync local env files with Vercel environments using better-env pull/load commands while preserving local overrides.
Managing Environment Variables with better-env + Vercel
Use better-env to sync local .env files with Vercel environments.
Install and configure
bun add better-env
bun run env:initbun add better-env
bun run env:initCreate better-env.ts in your project root:
import { defineBetterEnv, vercelAdapter } from "better-env";
export default defineBetterEnv({
adapter: vercelAdapter(),
environments: {
development: { envFile: ".env.development", remote: "development" },
preview: { envFile: ".env.preview", remote: "preview" },
production: { envFile: ".env.production", remote: "production" },
test: { envFile: ".env.test", remote: null },
},
});import { defineBetterEnv, vercelAdapter } from "better-env";
export default defineBetterEnv({
adapter: vercelAdapter(),
environments: {
development: { envFile: ".env.development", remote: "development" },
preview: { envFile: ".env.preview", remote: "preview" },
production: { envFile: ".env.production", remote: "production" },
test: { envFile: ".env.test", remote: null },
},
});Add scripts
{
"scripts": {
"env:init": "bunx --bun better-env init -y",
"env:pull": "bunx --bun better-env pull --environment=development",
"env:push": "bunx --bun better-env load .env.development --environment=development --upsert",
"env:pull:prod": "bunx --bun better-env pull --environment=production",
"env:push:prod": "bunx --bun better-env load .env.production --environment=production --upsert"
}
}{
"scripts": {
"env:init": "bunx --bun better-env init -y",
"env:pull": "bunx --bun better-env pull --environment=development",
"env:push": "bunx --bun better-env load .env.development --environment=development --upsert",
"env:pull:prod": "bunx --bun better-env pull --environment=production",
"env:push:prod": "bunx --bun better-env load .env.production --environment=production --upsert"
}
}Recommended file strategy
.env.development <- shared development values from Vercel
.env.production <- shared production values from Vercel
.env.local <- local-only overrides (never synced).env.development <- shared development values from Vercel
.env.production <- shared production values from Vercel
.env.local <- local-only overrides (never synced)better-env pull writes to your configured env file and keeps .env.local untouched.
Daily workflow
- Pull shared vars:
bun run env:pull - Add local overrides in
.env.local - Update remote vars with
bunx --bun better-env upsertorbun run env:push - Validate before building:
bun run env:validate
For scripts/config files that run outside Next.js runtime (for example Drizzle config), continue using loadEnvConfig(process.cwd()) before reading env values.
Build-Time Environment Validation with better-env
Validate all env-backed config modules with better-env before build and in CI.
Build-Time Environment Validation with better-env
Use better-env validate to fail fast when required environment variables are missing or invalid.
Install and initialize
bun add better-env
bun run env:initbun add better-env
bun run env:initenv:init runs bunx --bun better-env init -y, validates adapter prerequisites, and ensures your project is linked to the provider.
Add validation scripts
{
"scripts": {
"prebuild": "bun run env:validate:prod",
"env:validate": "bunx --bun better-env validate --environment=development",
"env:validate:prod": "bunx --bun better-env validate --environment=production"
}
}{
"scripts": {
"prebuild": "bun run env:validate:prod",
"env:validate": "bunx --bun better-env validate --environment=development",
"env:validate:prod": "bunx --bun better-env validate --environment=production"
}
}Validate locally
bun run env:validate
bun run env:validate:prodbun run env:validate
bun run env:validate:prodbetter-env validate:
- Loads
.env*files using Next.js semantics - Imports
src/lib/*/config.tsmodules - Runs
configSchemavalidation in each module - Reports missing or invalid values
- Warns about unused variables
Typical fix flow
When validation fails:
- Pull the latest variables:
bun run env:pull - Re-run validation:
bun run env:validate - Add/update missing values remotely via
bunx --bun better-env upsert ... - Pull + validate again
This replaces custom scripts/validate-env.ts scripts and keeps validation logic in the better-env CLI.