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 checks all your environment variables when the app starts. It lives in lib/validations/env.ts. Zod is a schema validation library. The app parses process.env against this schema once, then exports the result as a typed env object.

If a required variable is missing or has a bad value, the app stops right away with an error. This happens before any request is served, so a broken config fails at startup instead of crashing later.

The env object is fully typed. When you read env.DATABASE_URL or env.STRIPE_SECRET_KEY in your code, you get autocomplete and type checking for free.

A variable is required unless its rule ends in .optional() or .default(...). Each rule also checks the value:

  • z.url(): must be a valid URL, including the scheme (https://)
  • z.email(): must be a valid email address
  • z.string().min(n): must have at least n characters
  • z.string().startsWith("price_"): must start with price_ (Stripe price IDs)
  • z.enum([...]): must be one of a fixed set of values

Reference

Core / App

VariableRequiredDescription
PUBLIC_URLYesCanonical public base URL of the app (z.url())
NEXT_PUBLIC_APP_NAMENoDisplay name; defaults to next-starter
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 or openssl rand -base64 32.

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())
DIRECT_DATABASE_URLNoDirect (non-pooled) connection for migrations / connection poolers

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. Only that form uses it, so no sign-in, register, or password flow needs a token. Both keys are still required because env validation runs at startup regardless.

Create the widget in MANAGED mode and add localhost to its Hostnames for local dev. See the Turnstile guide.

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. Not in the Zod schema; read directly by Next.js

NEXT_TELEMETRY_DISABLED is the only variable in .env.example that is not validated by the schema. Everything else above maps one-to-one to lib/validations/env.ts.

.env.example

Copy this as your starting point (matches the repo's .env.example):

# Core
NODE_ENV="development"
PUBLIC_URL="http://localhost:3000"
NEXT_PUBLIC_APP_NAME="next-starter"

# Authentication
BETTER_AUTH_URL="http://localhost:3000"
NEXT_PUBLIC_BETTER_AUTH_URL="http://localhost:3000"
BETTER_AUTH_SECRET=""                    # Generate: pnpm dlx auth secret

# Google OAuth
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""

# Database
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/next_starter"
# DIRECT_DATABASE_URL=""                 # Direct connection for migrations

# Storage (Cloudflare R2)
STORAGE_S3_KEY=""
STORAGE_S3_SECRET=""
STORAGE_S3_REGION="auto"
STORAGE_S3_ENDPOINT=""                    # https://<account-id>.r2.cloudflarestorage.com
STORAGE_S3_BUCKET="next-starter"
NEXT_PUBLIC_STORAGE_S3_CDN_URL=""        # https://<bucket>.<account-id>.r2.dev

# Email (SMTP2Go)
SMTP2GO_API_KEY=""
SENDER_EMAIL="noreply@yourdomain.com"

# Cloudflare Turnstile
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
TURNSTILE_SECRET_KEY=""

# Stripe
STRIPE_SECRET_KEY=""                     # Use sk_test_ keys for development
STRIPE_WEBHOOK_SECRET=""
STRIPE_PRICE_PLUS_MONTHLY=""
STRIPE_PRICE_PLUS_ANNUAL=""
STRIPE_PRICE_PRO_MONTHLY=""
STRIPE_PRICE_PRO_ANNUAL=""

# Misc
NEXT_TELEMETRY_DISABLED=1

What you can change

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

  • Add a new variable. Add a rule to the envSchema object. Then add the value to your .env file and to each deployment (Vercel, etc.). Read it in server code with the typed env object: import { env } from "@/lib/validations/env".
  • Make a variable optional. Change its rule to end in .optional().
  • Give a variable a fallback. Add .default("some value") to its rule.
  • NEXT_PUBLIC_ variables are public. Next.js bakes them into the browser bundle at build time, so never put secrets in them. Read them anywhere with process.env.NEXT_PUBLIC_*. Adding one to the schema still checks it exists at startup.

On this page