SaaS Starter
Getting Started

Environment

Required and optional env vars. The schema in apps/server/src/bootstrap/env.ts is the source of truth.

All env vars are validated by a Zod schema in apps/server/src/bootstrap/env.ts. The server fails to boot with a clear error if a required var is missing or malformed. Optional adapters boot to a no-op when their config is unset — you never need to fill all the keys.

Minimum to boot

These four are the floor. Without them the server exits early:

BETTER_AUTH_SECRET=<openssl rand -base64 32>
BETTER_AUTH_URL=http://localhost:3005
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/boilerplate?schema=public
CORS_ORIGINS=http://localhost:3004

Generate the auth secret with openssl rand -base64 32. CORS_ORIGINS is a comma-separated list of allowed origins for the browser.

Auth providers (optional)

GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

Leave both empty to disable Google OAuth. Magic-link is configured through your email provider — see the notifications module.

Storage (optional)

STORAGE_PROVIDER=null            # null | local | s3 | uploadthing
# S3 / R2 / MinIO
STORAGE_S3_BUCKET=
STORAGE_S3_REGION=
STORAGE_S3_ACCESS_KEY_ID=
STORAGE_S3_SECRET_ACCESS_KEY=
STORAGE_S3_ENDPOINT=             # set for R2/MinIO; leave empty for AWS
STORAGE_S3_PUBLIC_BASE_URL=      # CDN base
# Local disk (dev)
STORAGE_LOCAL_ROOT=
STORAGE_LOCAL_PUBLIC_BASE_URL=
# UploadThing
UPLOADTHING_TOKEN=
UPLOADTHING_APP_ID=

STORAGE_PROVIDER=null (the default) skips registering an adapter so endpoints that need uploads return 503. The local provider builds public URLs from BETTER_AUTH_URL (the server's URL), not APP_URL — uploads are served by the API, not by Next.js.

Background jobs (optional)

REDIS_URL=redis://localhost:6379
JOBS_REDIS_URL=                  # defaults to REDIS_URL
JOBS_PREFIX=bull
BULL_BOARD_ENABLED=true          # mounts /admin/queues UI

Without Redis, BullMQ is registered but no worker connects. BULL_BOARD_ENABLED=true mounts a guarded Bull Board UI at /admin/queues.

Rate limiting

RATE_LIMIT_REDIS_ENABLED=true    # falls back to memory when REDIS_URL is unset
RATE_LIMIT_READ_PER_MIN=300
RATE_LIMIT_WRITE_PER_MIN=60
RATE_LIMIT_AUTH_IP_PER_MIN=30
RATE_LIMIT_AUTH_EMAIL_PER_15MIN=5

The four tiers (read, write, auth-ip, auth-email) each get their own Redis prefix (rl:read:, rl:write:, etc.) so counters never collide. The limiter handlers are memoized on the RateLimitDeps reference, so memory-mode behaves identically to Redis-mode (one shared store per tier).

Observability (optional)

SENTRY_DSN=                      # leave empty to disable
SENTRY_TRACES_SAMPLE_RATE=0.1
APP_VERSION=

OTEL_EXPORTER_OTLP_ENDPOINT=     # leave empty to disable trace export
OTEL_SERVICE_NAME=mern-saas-server

METRICS_ENABLED=true             # Prometheus /metrics endpoint

OTel must boot before Express/Prisma load. The first import in apps/server/src/index.ts is ./otel-init.js for that reason. Don't reorder it.

The Sentry server SDK is @sentry/bun (not @sentry/node). logger.error(...) does not auto-capture; use the captureError(err, ctx) helper for errors that should page someone.

Billing (provider-dependent)

Set PAYMENT_PROVIDER to one of stripe, mercado-pago, polar, then fill that provider's keys:

PAYMENT_PROVIDER=stripe
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# or
PAYMENT_PROVIDER=mercado-pago
MERCADO_PAGO_ACCESS_TOKEN=
MERCADO_PAGO_WEBHOOK_SECRET=
# or
PAYMENT_PROVIDER=polar
POLAR_ACCESS_TOKEN=
POLAR_WEBHOOK_SECRET=

See Billing for the strategy and webhook setup.

Usage metering (optional)

Disabled by default. Flip the flag once the downstream has registered its meters and a plan → cap map.

USAGE_METERING_ENABLED=false           # set to "true" to enforce quotas
USAGE_FREE_PRICE_ID=price_free_synthetic  # synthetic key for the Free tier; not a real Polar price

See Usage metering for the full reference.

Adding a new variable

  1. Edit apps/server/src/bootstrap/env.ts and extend the Zod schema. Required vars must .min(1); optional ones use .optional() or .default(...).
  2. Add a documented entry in apps/server/.env.example.
  3. Read it from env.YOUR_VAR — never from process.env directly.

On this page