Back to recipes

Environment Variable Management

3 recipes

Complete better-env workflow: typed config schema, Vercel sync, and prebuild validation.

Cookbooks

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

bash
bun add better-env
bunx skills add neondatabase/better-env -a cursor -a codex -y

Define feature-level config modules

Create config modules in src/lib/*/config.ts.

ts
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:

ts
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.

ts
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.

ts
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.

typescript
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

bash
bun add better-env
bun run env:init

Create better-env.ts in your project root:

typescript
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

json
{
  "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

text
.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

  1. Pull shared vars: bun run env:pull
  2. Add local overrides in .env.local
  3. Update remote vars with bunx --bun better-env upsert or bun run env:push
  4. 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

bash
bun add better-env
bun run env:init

env:init runs bunx --bun better-env init -y, validates adapter prerequisites, and ensures your project is linked to the provider.

Add validation scripts

json
{
  "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

bash
bun run env:validate
bun run env:validate:prod

better-env validate:

  1. Loads .env* files using Next.js semantics
  2. Imports src/lib/*/config.ts modules
  3. Runs configSchema validation in each module
  4. Reports missing or invalid values
  5. Warns about unused variables

Typical fix flow

When validation fails:

  1. Pull the latest variables: bun run env:pull
  2. Re-run validation: bun run env:validate
  3. Add/update missing values remotely via bunx --bun better-env upsert ...
  4. Pull + validate again

This replaces custom scripts/validate-env.ts scripts and keeps validation logic in the better-env CLI.