Next Starter Logo
Security

Environment Variables

How Next Starter validates environment variables with Zod at startup, plus a grouped config reference for every required and optional variable.

How it works

One Zod schema, lib/validations/env.ts, holds the rules for your environment variables. It parses process.env and exports the result as a typed env object, so env.DATABASE_URL autocompletes and env.NOPE fails the type check.

The parse sits at module scope, which means it runs the first time anything imports env. That happens early. lib/auth.ts imports env, the site layout in app/(site)/layout.tsx reads the session through it, and next build therefore trips over a bad value the moment it loads the homepage rather than letting a broken config limp into production.

A variable is required unless its rule ends in .optional() or .default(...). Where a rule constrains the value beyond "not empty", the table below says so.

Reference

Every variable here has a rule in lib/validations/env.ts, with one exception noted at the end.

Core / App

VariableRequiredDescription
NEXT_PUBLIC_APP_NAMENoPrefix for every R2 storage key, read by buildStorageKey in lib/file-utils.ts; defaults to next-starter. The visible app name is APP_CONFIG.name in lib/config.ts
NODE_ENVNodevelopment | production | test; defaults to development

Authentication

VariableRequiredDescription
BETTER_AUTH_URLYesFull app URL used by Better Auth for redirects (z.url())
BETTER_AUTH_SECRETYesSession signing secret, minimum 32 characters
NEXT_PUBLIC_BETTER_AUTH_URLNoPublic-facing Better Auth URL for the client SDK (z.url())

Generate a secret with pnpm dlx auth secret.

Google OAuth

VariableRequiredDescription
GOOGLE_CLIENT_IDYesOAuth 2.0 client ID from Google Cloud Console
GOOGLE_CLIENT_SECRETYesOAuth 2.0 client secret from Google Cloud Console

Database

VariableRequiredDescription
DATABASE_URLYesPostgreSQL connection string used by Prisma (z.url())

Storage (Cloudflare R2)

VariableRequiredDescription
STORAGE_S3_KEYYesR2 access key ID
STORAGE_S3_SECRETYesR2 secret access key
STORAGE_S3_REGIONYesRegion identifier (use auto for R2)
STORAGE_S3_ENDPOINTYesR2 S3-compatible endpoint URL (z.url())
STORAGE_S3_BUCKETYesName of the R2 bucket
NEXT_PUBLIC_STORAGE_S3_CDN_URLYesPublic CDN URL for serving stored files (z.url())

Email (SMTP2Go)

VariableRequiredDescription
SMTP2GO_API_KEYYesAPI key from your SMTP2Go account
SENDER_EMAILYesFrom address for outgoing email; must be a valid email and verified in SMTP2Go

Cloudflare Turnstile

VariableRequiredDescription
NEXT_PUBLIC_TURNSTILE_SITE_KEYYesPublic site key for the Turnstile widget
TURNSTILE_SECRET_KEYYesSecret key for server-side token verification

Turnstile guards the contact form and nothing else, but the schema requires both keys unconditionally, so every deployment needs them even if you strip that form out. The Turnstile guide covers creating the widget.

Stripe

VariableRequiredDescription
STRIPE_SECRET_KEYYesSecret key from the Stripe dashboard (use sk_test_ in dev)
STRIPE_WEBHOOK_SECRETYesWebhook signing secret for the Stripe endpoint
STRIPE_PRICE_PLUS_MONTHLYYesPrice ID for the Plus plan, billed monthly (must start with price_)
STRIPE_PRICE_PLUS_ANNUALYesPrice ID for the Plus plan, billed annually (must start with price_)
STRIPE_PRICE_PRO_MONTHLYYesPrice ID for the Pro plan, billed monthly (must start with price_)
STRIPE_PRICE_PRO_ANNUALYesPrice ID for the Pro plan, billed annually (must start with price_)

The four price IDs are wired to the subscription plans in lib/auth.ts. Get the webhook secret locally with stripe listen --forward-to localhost:3000/api/auth/stripe/webhook.

Misc

VariableRequiredDescription
NEXT_TELEMETRY_DISABLEDNoSet to 1 to disable Next.js telemetry. Read by Next.js itself, and the one entry in .env.example with no rule in the schema

Filling in .env

cp .env.example .env

That file carries the same grouping as the tables above, with a note on where each credential comes from. Local URLs, the Postgres connection string, and a handful of defaults arrive filled in. The credentials arrive empty. To see what's left, search for ="" and ignore the commented-out lines. Every match is required by the schema, and the app won't start until it has a value.

The schema checks shape, not whether a credential works. STRIPE_SECRET_KEY only has to be a non-empty string, so a revoked key passes validation and fails at the first API call. Get Started covers stand-in values that get the app running before you've signed up for every service.

What you can change

Schema changes happen in one file: lib/validations/env.ts.

  • Add a new variable. Put a rule in the envSchema object, set the value in .env and in each deployment target, then read it with import { env } from "@/lib/validations/env".
  • Relax an existing one. End its rule in .optional() to make it optional, or .default("value") to give it a fallback.
  • NEXT_PUBLIC_ variables. Next.js bakes them into the browser bundle at build time, so never put a secret in one. A schema rule can still check it at startup, but the value ships to every visitor either way.

On this page