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 addressz.string().min(n): must have at leastncharactersz.string().startsWith("price_"): must start withprice_(Stripe price IDs)z.enum([...]): must be one of a fixed set of values
Reference
Core / App
| Variable | Required | Description |
|---|---|---|
PUBLIC_URL | Yes | Canonical public base URL of the app (z.url()) |
NEXT_PUBLIC_APP_NAME | No | Display name; defaults to next-starter |
NODE_ENV | No | development | production | test; defaults to development |
Authentication
| Variable | Required | Description |
|---|---|---|
BETTER_AUTH_URL | Yes | Full app URL used by Better Auth for redirects (z.url()) |
BETTER_AUTH_SECRET | Yes | Session signing secret, minimum 32 characters |
NEXT_PUBLIC_BETTER_AUTH_URL | No | Public-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
| Variable | Required | Description |
|---|---|---|
GOOGLE_CLIENT_ID | Yes | OAuth 2.0 client ID from Google Cloud Console |
GOOGLE_CLIENT_SECRET | Yes | OAuth 2.0 client secret from Google Cloud Console |
Database
| Variable | Required | Description |
|---|---|---|
DATABASE_URL | Yes | PostgreSQL connection string used by Prisma (z.url()) |
DIRECT_DATABASE_URL | No | Direct (non-pooled) connection for migrations / connection poolers |
Storage (Cloudflare R2)
| Variable | Required | Description |
|---|---|---|
STORAGE_S3_KEY | Yes | R2 access key ID |
STORAGE_S3_SECRET | Yes | R2 secret access key |
STORAGE_S3_REGION | Yes | Region identifier (use auto for R2) |
STORAGE_S3_ENDPOINT | Yes | R2 S3-compatible endpoint URL (z.url()) |
STORAGE_S3_BUCKET | Yes | Name of the R2 bucket |
NEXT_PUBLIC_STORAGE_S3_CDN_URL | Yes | Public CDN URL for serving stored files (z.url()) |
Email (SMTP2Go)
| Variable | Required | Description |
|---|---|---|
SMTP2GO_API_KEY | Yes | API key from your SMTP2Go account |
SENDER_EMAIL | Yes | From address for outgoing email; must be a valid email and verified in SMTP2Go |
Cloudflare Turnstile
| Variable | Required | Description |
|---|---|---|
NEXT_PUBLIC_TURNSTILE_SITE_KEY | Yes | Public site key for the Turnstile widget |
TURNSTILE_SECRET_KEY | Yes | Secret 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
| Variable | Required | Description |
|---|---|---|
STRIPE_SECRET_KEY | Yes | Secret key from the Stripe dashboard (use sk_test_ in dev) |
STRIPE_WEBHOOK_SECRET | Yes | Webhook signing secret for the Stripe endpoint |
STRIPE_PRICE_PLUS_MONTHLY | Yes | Price ID for the Plus plan, billed monthly (must start with price_) |
STRIPE_PRICE_PLUS_ANNUAL | Yes | Price ID for the Plus plan, billed annually (must start with price_) |
STRIPE_PRICE_PRO_MONTHLY | Yes | Price ID for the Pro plan, billed monthly (must start with price_) |
STRIPE_PRICE_PRO_ANNUAL | Yes | Price 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
| Variable | Required | Description |
|---|---|---|
NEXT_TELEMETRY_DISABLED | No | Set 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=1What you can change
All changes happen in one file: lib/validations/env.ts.
- Add a new variable. Add a rule to the
envSchemaobject. Then add the value to your.envfile and to each deployment (Vercel, etc.). Read it in server code with the typedenvobject: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 withprocess.env.NEXT_PUBLIC_*. Adding one to the schema still checks it exists at startup.
Forms
How Next Starter builds forms: Zod 4 schemas for validation, typed Server Actions, React Hook Form with Field primitives, and Turnstile on the contact form.
Security Headers & CSP
How Next Starter configures HTTP security headers and a Content Security Policy in next.config.ts, and how to add your own external domains.