Create a .env.local file in your project root. For a first local run, prefer pnpm run init; it writes the same contract from guided prompts and avoids missing required values.
NEXT_PUBLIC_DEFAULT_LOCALE uses a hyphen (BCP 47): en-US, fr-FR. The underscore form (en_US) is the OpenGraph spelling and is not a valid locale id — i18n/config.ts only matches hyphenated ids. Older .env files carried this typo; pnpm run init now normalizes it and warns instead of copying it forward.
Environment by scenario
Use this table before opening the full env list. It tells you which variables matter for the stage you are working on.
| Scenario | Required values | Notes |
|---|---|---|
| Minimal local app | NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY, SUPABASE_SECRET_KEY, legal metadata, email sender values |
Use EMAIL_PROVIDER=noop if you only want to render the app before wiring real email delivery. |
| Authentication | EMAIL_PROVIDER, provider API keys, EMAIL_FROM_NAME, EMAIL_FROM_ADDRESS, optional NEXT_PUBLIC_OAUTH_PROVIDERS, OTP_LENGTH |
OTP_LENGTH must match the Supabase Auth Email OTP length setting. |
| Stripe billing | STRIPE_SECRET_KEY, NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET, price IDs in config/pricing.ts |
Plans, credit packs, licenses, currencies, and Stripe Price IDs live in config/pricing.ts, not in the database. |
| AI / RAG | AI_LLM_TRANSPORT plus either OPENROUTER_API_KEY or a matching direct-provider key |
The selected route applies to chat and embeddings. OpenRouter requests enforce ZDR/data-collection-deny routing. Credits are decremented from provider-reported token usage. |
| B2B workspaces | businessModel: 'b2b' in config/app.ts, workspace settings in config/workspace.ts |
Every B2B subscription targets a workspace account. New users get a workspace through the automatic B2B bootstrap; /onboarding/workspace is a fallback. |
| Production monitoring | LOGS_ENABLED, LOGS_RETENTION_DAYS, LOGS_SALT, JOBS_SECRET_KEY, Upstash Redis values |
Set salts and secrets in your hosting platform, not in committed files. Logs return 404 while disabled. |
| Analytics / marketing | NEXT_PUBLIC_GTM_ID, NEXT_PUBLIC_GA_MEASUREMENT_ID, Google Ads / Meta / X / Crisp IDs as needed |
All third-party analytics and live-chat scripts are consent-gated by default. |
Variables required for a production-ready boot. Many are still useful in local dev (Supabase, email sender values); the Notes column flags which gate each variable controls so you can skip the ones that are not relevant to your stage.
| Variable | Required for | Description |
|---|---|---|
NEXT_PUBLIC_APP_URL | app boot | Canonical site origin. Read in config/app.ts as appConfig.url; used by Stripe redirects, sitemap, CSRF Origin/Referer check, and absolute-URL generation. Use the production domain (e.g. https://example.com); for local dev http://localhost:3777 is fine. |
NEXT_PUBLIC_DEFAULT_LOCALE | documentation only | BCP-47 default locale (hyphen, not underscore). Must be one of the configured locales in i18n/config.ts. No runtime code reads this variable — the effective default comes from defaultLocale in i18n/config.ts, which is what config/app.ts, proxy.ts (auto-detect fallback) and app/sitemap.ts (priority multiplier) import. Change the locale there, not here. |
NEXT_PUBLIC_SUPABASE_URL | app boot | Your Supabase project URL (Settings → API) |
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY | app boot | Supabase publishable key for client-side access |
SUPABASE_SECRET_KEY | server / RLS bypass | Supabase secret key for server-side operations (never expose to client) |
SUPABASE_POSTGRES_URL | init script | Direct Postgres connection string consumed by scripts/init-project.js to run schema/migration SQL and seed jobs. Find it under Settings → Database → Connection string → URI in the Supabase Dashboard. Not used at request time. |
STRIPE_SECRET_KEY | billing | Stripe secret key for payment processing |
STRIPE_WEBHOOK_SECRET | billing | Webhook signing secret from Stripe Dashboard |
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY | billing | Stripe publishable key for client-side Checkout |
CONTACT_EMAIL | app boot | Contact-form inbox. Surfaced in the footer and used as the To address for messages submitted on /contact. Confirmation copies and admin templates also read this value via appConfig.contact.email. |
SUPPORT_EMAIL | app boot | Support inbox shown to logged-in users (My Account, error pages, license expiration notices). Defaults to CONTACT_EMAIL when the init wizard runs without an explicit value. |
EMAIL_PROVIDER | auth / transactional email | Active email provider — brevo | mailjet | noop (default: brevo). Use noop for dev / CI when you don't want to deliver anything. |
EMAIL_FROM_NAME | auth / transactional email | Sender name for outgoing emails |
EMAIL_FROM_ADDRESS | auth / transactional email | Sender email address (must be verified with the active provider) |
BREVO_API_KEY | when EMAIL_PROVIDER=brevo | Brevo API key |
BREVO_NEWSLETTER_LIST_ID | when EMAIL_PROVIDER=brevo | Brevo contact list id for newsletter |
MAILJET_API_KEY_PUBLIC | when EMAIL_PROVIDER=mailjet | Mailjet public key |
MAILJET_API_KEY_PRIVATE | when EMAIL_PROVIDER=mailjet | Mailjet private/secret key |
MAILJET_NEWSLETTER_LIST_ID | optional (Mailjet) | Mailjet contact list id for newsletter |
LEGAL_COMPANY_NAME | public/indexable builds | Legal company name shown on public legal pages. Required before enabling public/indexable production builds. |
LEGAL_ADDRESS | public/indexable builds | Registered company address shown on public legal pages. Required before enabling public/indexable production builds. |
LEGAL_REGISTRATION_NUMBER | public/indexable builds | Company registration number (RCS, SIRET, UID, etc.). Required before enabling public/indexable production builds. |
These variables enable additional features but are not required to start:
| Variable | Description |
|---|---|
STRIPE_WEBHOOK_SECRET_PREVIOUS | Server-only. Previous Stripe endpoint signing secret accepted temporarily during a versioned webhook cutover. Remove it after the old endpoint is disabled and Dahlia deliveries succeed. |
AI_LLM_TRANSPORT | Server-only. direct (default) or openrouter. One value controls every chat and embedding request through lib/ai/llm-client.ts. |
OPENROUTER_API_KEY | Required when AI_LLM_TRANSPORT=openrouter. Never expose it through a NEXT_PUBLIC_* variable. |
OPENROUTER_BASE_URL | Server-only. Defaults to https://openrouter.ai/api/v1. Production rejects alternative origins; only the named loopback QA mode may override it. |
OPENAI_API_KEY | OpenAI API key for GPT models |
OPENAI_BASE_URL | Server-only. Optional direct-OpenAI /v1 base URL. Defaults to OpenAI; embedded credentials, query strings, and fragments are rejected. |
ANTHROPIC_API_KEY | Anthropic API key for Claude models |
GOOGLE_AI_API_KEY | Google AI API key for Gemini models |
NEXT_PUBLIC_TURNSTILE_SITE_KEY | Cloudflare Turnstile site key for bot protection |
TURNSTILE_SECRET_KEY | Cloudflare Turnstile server-side secret |
NEXT_PUBLIC_GTM_ID | Google Tag Manager container ID (GTM-XXX) |
NEXT_PUBLIC_GA_MEASUREMENT_ID | Google Analytics 4 Measurement ID (G-XXX) — primary ID for gtag.js |
NEXT_PUBLIC_GOOGLE_ADS_ID | Google Ads ID for conversion tracking (e.g. AW-XXXXXXXXXXX) |
NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL | Legacy default Google Ads conversion label (fallback) |
NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_PURCHASE | Google Ads purchase conversion tag (format: AW-XXX/LABEL) |
NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_SIGNUP | Google Ads signup conversion tag (format: AW-XXX/LABEL) |
NEXT_PUBLIC_META_PIXEL_ID | Meta (Facebook) Pixel ID for ad tracking |
NEXT_PUBLIC_X_PIXEL_ID | X (Twitter) Pixel base ID for ad tracking |
NEXT_PUBLIC_X_PIXEL_EVENT_PURCHASE | X Pixel purchase conversion event ID (format: tw-XXX-XXX) |
NEXT_PUBLIC_X_PIXEL_EVENT_SIGNUP | X Pixel signup conversion event ID (format: tw-XXX-XXX) |
NEXT_PUBLIC_CRISP_WEBSITE_ID | Crisp live chat website ID |
UPSTASH_REDIS_REST_URL | Required in production. Shared Upstash Redis URL for distributed rate limits and LLM circuit-breaker state. Without a shared store each instance counts separately and a caller is load-balanced across all of them, which removes effective rate limiting; LLM circuit state would also disagree between workers. The app refuses to boot in production rather than starting with every rate limit disabled. Development uses process-local counters and the same circuit state machine. |
UPSTASH_REDIS_REST_TOKEN | Upstash Redis authentication token used by rate limits and the LLM circuit breaker. Required alongside the URL. |
ALLOW_INMEMORY_RATE_LIMIT | Server-only. Escape hatch for the boot check above. true permits per-instance in-memory rate limiting in production — correct only on a deliberately single-instance deployment. Do not set it to make a deploy pass. Default false. |
TRUST_CLOUDFLARE_IP | Set to true only when the origin is locked behind Cloudflare or your edge strips spoofed proxy headers. When disabled, rate limiting ignores spoofable CF-Connecting-IP headers and falls back to proxy headers such as X-Forwarded-For. |
JOBS_SECRET_KEY | Secret key for authenticating cron and webhook job triggers |
DEFAULT_TRIAL_PERIOD_DAYS | Default free trial length in days for subscription plans configured with trialDays. Unset/blank → 7. 0 disables trials. Clamped to 730 (Stripe max). |
DEFAULT_TRIAL_CREDITS | Credits granted up-front when a subscription enters status='trialing' so the user can use the app during the trial. The first paid invoice tops up the difference (full plan allotment − trial amount). Capped at the plan's included credits. Unset/0 → trialing users get nothing until the trial converts. |
PRELAUNCH_ALLOWED_EMAILS | Server-only. Comma-separated emails allowed to complete sign-in (magic link + OAuth) while NEXT_PUBLIC_PRELAUNCH=true. Case-insensitive. Empty = sign-in fully locked. See Sign-In Allowlist. |
NEXT_PUBLIC_OAUTH_PROVIDERS | Comma-separated provider ids rendered as buttons on /login (e.g. google,github,apple). Each id must also be enabled in the Supabase Auth dashboard. Unknown ids drop silently. See supported ids. Default: google. |
OTP_LENGTH | Server-only. Length of the magic-link 6-digit code (range 6–10, default 6). MUST match the project's Authentication → Email OTP length setting in the Supabase dashboard — Supabase generates the actual code; this env var tells our UI/API how many digits to expect. Read once in lib/env.ts; exposed as appConfig.auth.otpLength. See Configuring the code length. |
Copy the .env.example file to .env.local and fill in all values. The example file contains every variable with descriptive comments explaining each one. Variables prefixed with NEXT_PUBLIC_ are exposed to the browser and should only contain non-sensitive values. All other variables remain server-side only.
Set NEXT_PUBLIC_ONBOARDING_ENABLED=true to enable the onboarding flow after first login. Set NEXT_PUBLIC_PRELAUNCH=false to disable prelaunch/waitlist mode. Set NEXT_PUBLIC_INDEXABLE=true to allow search engine indexing (defaults to blocked when not set).
Feature-gate & secret env vars
These power optional domains and are read once in their config file (never process.env in components). Server-only salts are required in production — a missing salt disables hashing, not the feature.
| Variable | Purpose |
|---|---|
REFERRAL_ENABLED / REFERRAL_SALT | Referral program gate (404 when off) + IP/UA hash salt. Read in config/referral.ts. |
AFFILIATES_ENABLED / AFFILIATES_SALT | Affiliate program gate + hash salt (distinct from REFERRAL_SALT). Read in config/affiliates.ts. |
LOGS_ENABLED / LOGS_RETENTION_DAYS / LOGS_SALT | Error-log dashboard gate, purge window (default 90), IP hash salt. Read in config/app.ts. |
TRUST_CLOUDFLARE_IP | Rate-limiter trust knob for Cloudflare's CF-Connecting-IP header. Keep false unless direct origin access is blocked or the trusted proxy strips spoofed headers. |
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY | Web Push keys (server-only, no NEXT_PUBLIC_). Generate with pnpm exec web-push generate-vapid-keys. |
NEXT_PUBLIC_PUSH_ENABLED | Toggles the PWA push UI. |
OTP_LENGTH | Magic-link code length (6–10, default 6). MUST match the Supabase Auth Email-OTP-length setting. |
LEGAL_COMPANY_NAME / LEGAL_ADDRESS / LEGAL_REGISTRATION_NUMBER | Public legal metadata read in config/app.ts. Example placeholders block public/indexable production builds. |
NEXT_PUBLIC_GTM_ID, NEXT_PUBLIC_GA_MEASUREMENT_ID, NEXT_PUBLIC_META_PIXEL_ID, NEXT_PUBLIC_X_PIXEL_ID, NEXT_PUBLIC_GOOGLE_ADS_ID, NEXT_PUBLIC_CRISP_WEBSITE_ID | Analytics / live-chat integrations (consent-aware). Validated in lib/env.ts. |
NEXT_PUBLIC_ONBOARDING_ENABLED, NEXT_PUBLIC_PRELAUNCH, NEXT_PUBLIC_INDEXABLE | Onboarding flow, prelaunch/waitlist mode, search-engine indexing gate. |
NEXT_PUBLIC_INSTANCE_MODE | Deployment mode flag shipped in .env.example (e.g. development / production). Set per environment. |
.env.example and pnpm run init declare the supported env contract. When adding a new public knob or server-only secret, update both the example file and the init script so new projects do not miss required runtime configuration.
OAuth supported ids
NEXT_PUBLIC_OAUTH_PROVIDERS is a comma-separated list of Supabase provider ids rendered as buttons on /login. Each id must also be enabled in the Supabase Auth dashboard; unknown ids drop silently.
Supported ids (matching the Supabase Provider type): apple, azure, bitbucket, discord, facebook, figma, github, gitlab, google, kakao, keycloak, linkedin (legacy), linkedin_oidc, notion, slack (legacy), slack_oidc, spotify, twitch, twitter. Empty value = magic link only. Default: google.
Configuring the magic-link code length
OTP_LENGTH is the number of digits in the 6-digit-style code embedded in magic-link emails. Range: 6–10, default 6. Server-only (no NEXT_PUBLIC_ prefix). MUST match the value of Authentication → Email OTP length in the Supabase dashboard — Supabase generates the code, this env var tells our UI/API how many digits to validate. Read once in lib/env.ts; exposed elsewhere as appConfig.auth.otpLength.
Sign-in allowlist (prelaunch mode)
While NEXT_PUBLIC_PRELAUNCH=true, sign-in is gated server-side by PRELAUNCH_ALLOWED_EMAILS — a comma-separated allowlist (case-insensitive). The magic-link API blocks non-allowlisted emails before calling admin.generateLink, and the OAuth callback signs the user out and deletes the auto-created auth user if their email is not on the list. Leave empty to fully lock sign-in during the prelaunch window. Flip NEXT_PUBLIC_PRELAUNCH to false to disable the gate entirely.