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
| Variable | Required | Description |
|---|---|---|
NEXT_PUBLIC_APP_NAME | No | Prefix 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_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.
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()) |
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 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
| 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. Read by Next.js itself, and the one entry in .env.example with no rule in the schema |
Filling in .env
cp .env.example .envThat 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
envSchemaobject, set the value in.envand in each deployment target, then read it withimport { 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.
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 and 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.