Environment Variables
How Next Starter validates environment variables with Zod at startup, plus a complete reference for every required and optional variable: DATABASE_URL, BETTER_AUTH_SECRET, Stripe, SMTP2Go, Cloudflare R2, and more.
Overview
All environment variables are validated at application startup using Zod. If a required variable is missing or has an invalid value, the process throws immediately, before any request is served. Misconfigured deployments fail fast rather than failing silently at runtime.
The validation schema lives in lib/validations/env.ts and exports a typed env object used throughout the codebase.
How Validation Works
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.url(),
DIRECT_DATABASE_URL: z.url().optional(),
BETTER_AUTH_URL: z.url(),
BETTER_AUTH_SECRET: z.string().min(32),
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
SMTP2GO_API_KEY: z.string().min(1),
SENDER_EMAIL: z.email(),
PUBLIC_URL: z.url(),
STORAGE_S3_KEY: z.string().min(1),
STORAGE_S3_SECRET: z.string().min(1),
STORAGE_S3_REGION: z.string().min(1),
STORAGE_S3_ENDPOINT: z.url(),
STORAGE_S3_BUCKET: z.string().min(1),
NEXT_PUBLIC_STORAGE_S3_CDN_URL: z.url(),
NEXT_PUBLIC_APP_NAME: z.string().default("next-starter"),
STRIPE_SECRET_KEY: z.string().min(1),
STRIPE_PUBLISHABLE_KEY: z.string().min(1),
STRIPE_WEBHOOK_SECRET: z.string().min(1),
RECAPTCHA_SECRET_KEY: z.string().optional(),
NEXT_PUBLIC_RECAPTCHA_SITE_KEY: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().min(1),
GOOGLE_CLIENT_SECRET: z.string().min(1),
NEXT_PUBLIC_BETTER_AUTH_URL: z.url().optional(),
});
export const env = envSchema.parse(process.env);The env object is fully typed. TypeScript infers the shape from the schema, so you get autocompletion and type checking when you access env.DATABASE_URL, env.STRIPE_SECRET_KEY, etc.
Complete Variable Reference
Database
| Variable | Required | Description |
|---|---|---|
DATABASE_URL | Yes | PostgreSQL connection string used by Prisma for queries |
DIRECT_DATABASE_URL | No | Direct (non-pooled) connection string, required when using PgBouncer or a connection pooler |
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
DIRECT_DATABASE_URL="postgresql://user:password@localhost:5432/mydb"Authentication
| Variable | Required | Description |
|---|---|---|
BETTER_AUTH_URL | Yes | The full URL of your application, used by Better Auth for redirects |
BETTER_AUTH_SECRET | Yes | Secret key for signing sessions (minimum 32 characters) |
NEXT_PUBLIC_BETTER_AUTH_URL | No | Public-facing Better Auth URL, used by the client SDK |
BETTER_AUTH_URL="https://yourdomain.com"
BETTER_AUTH_SECRET="a-very-long-random-secret-at-least-32-chars"Generate a secure secret with:
pnpm dlx @better-auth/cli secretOr using OpenSSL:
openssl rand -base64 32Google 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 |
GOOGLE_CLIENT_ID="123456789-abc.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="GOCSPX-..."| Variable | Required | Description |
|---|---|---|
SMTP2GO_API_KEY | Yes | API key from your SMTP2Go account |
SENDER_EMAIL | Yes | From address for outgoing emails (must be verified in SMTP2Go) |
PUBLIC_URL | Yes | Canonical public base URL of the application |
SMTP2GO_API_KEY="api-..."
SENDER_EMAIL="noreply@yourdomain.com"
PUBLIC_URL="https://yourdomain.com"File 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 |
STORAGE_S3_BUCKET | Yes | Name of the R2 bucket |
NEXT_PUBLIC_STORAGE_S3_CDN_URL | Yes | Public CDN URL for serving stored files |
STORAGE_S3_KEY="your-r2-access-key-id"
STORAGE_S3_SECRET="your-r2-secret-access-key"
STORAGE_S3_REGION="auto"
STORAGE_S3_ENDPOINT="https://<account-id>.r2.cloudflarestorage.com"
STORAGE_S3_BUCKET="my-bucket"
NEXT_PUBLIC_STORAGE_S3_CDN_URL="https://cdn.yourdomain.com"Stripe
| Variable | Required | Description |
|---|---|---|
STRIPE_SECRET_KEY | Yes | Secret key from your Stripe dashboard |
STRIPE_PUBLISHABLE_KEY | Yes | Publishable key from your Stripe dashboard |
STRIPE_WEBHOOK_SECRET | Yes | Webhook signing secret from a Stripe webhook endpoint |
STRIPE_SECRET_KEY="sk_live_..."
STRIPE_PUBLISHABLE_KEY="pk_live_..."
STRIPE_WEBHOOK_SECRET="whsec_..."Use sk_test_ and pk_test_ keys during development. Get the webhook secret by running the Stripe CLI listener locally:
stripe listen --forward-to localhost:3000/api/auth/stripe/webhookreCAPTCHA
| Variable | Required | Description |
|---|---|---|
NEXT_PUBLIC_RECAPTCHA_SITE_KEY | No | Public site key for the reCAPTCHA v3 widget |
RECAPTCHA_SECRET_KEY | No | Secret key for server-side token verification |
Both variables are optional. When omitted in development, reCAPTCHA verification passes automatically. See the reCAPTCHA guide for details.
Application
| Variable | Required | Description |
|---|---|---|
NEXT_PUBLIC_APP_NAME | No | Display name for the application (defaults to next-starter) |
NODE_ENV | No | Runtime environment: development, production, or test (defaults to development) |
NEXT_TELEMETRY_DISABLED | No | Set to 1 to disable Next.js anonymous telemetry data collection |
.env.example
Copy this as your starting point:
# 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 @better-auth/cli 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
# File Storage (Cloudflare R2)
STORAGE_S3_KEY=""
STORAGE_S3_SECRET=""
STORAGE_S3_REGION="auto"
STORAGE_S3_ENDPOINT=""
STORAGE_S3_BUCKET="next-starter"
NEXT_PUBLIC_STORAGE_S3_CDN_URL=""
# Email
SMTP2GO_API_KEY=""
SENDER_EMAIL="noreply@yourdomain.com"
# reCAPTCHA (optional)
NEXT_PUBLIC_RECAPTCHA_SITE_KEY=""
RECAPTCHA_SECRET_KEY=""
# Stripe
STRIPE_SECRET_KEY="" # Use sk_test_ keys for development
STRIPE_PUBLISHABLE_KEY="" # Use pk_test_ keys for development
STRIPE_WEBHOOK_SECRET="" # From: stripe listen --forward-to localhost:3000/api/auth/stripe/webhook
# Misc
NEXT_TELEMETRY_DISABLED=1Adding a New Environment Variable
- Add the variable to the schema in
lib/validations/env.ts:
const envSchema = z.object({
// existing variables...
MY_API_KEY: z.string().min(1),
MY_OPTIONAL_FLAG: z.string().optional(),
});-
Add the variable to your
.envfile and to any deployment environment (Railway, Fly.io, Docker, etc.). -
Access it anywhere in server-side code via the typed
envobject:
import { env } from "@/lib/validations/env";
const client = createClient({ apiKey: env.MY_API_KEY });For NEXT_PUBLIC_ variables used in client components, access them directly via process.env.NEXT_PUBLIC_MY_VAR. Next.js inlines these at build time, so no import is needed. Adding them to the schema still validates they are present at startup.
Common Errors
ZodError: invalid_type on startup. A required variable is missing from your .env file or deployment environment. The error message includes the variable name.
BETTER_AUTH_SECRET too short. The secret must be at least 32 characters. Generate one with pnpm dlx @better-auth/cli secret or openssl rand -base64 32.
DATABASE_URL validation failure. Zod uses z.url(), which requires a valid URL format including the scheme. Ensure the value starts with postgresql:// or postgres://.
Forms
How Next Starter handles forms. Zod 4 validation schemas, Server Actions with reCAPTCHA, and React Hook Form client components wired together with shadcn/ui primitives.
Security Headers & CSP
How Next Starter configures HTTP security headers, including Content Security Policy, HSTS, X-Frame-Options, Referrer-Policy, and more, inside next.config.ts.