A production-ready SaaS boilerplate built with Next.js 16 App Router, a multi-provider auth system (Clerk, Better Auth, or NextAuth), a multi-payment provider architecture (Stripe, Paystack, Paddle, Razorpay), Prisma 7 with a committed PostgreSQL migration baseline, and a full-featured admin dashboard.
For the hosted documentation, start with Getting Started, SEO & Discoverability, Deployment, and Secrets. The repository markdown files are the deeper operator notes and copy-paste examples behind those pages.
Support ongoing development via GitHub Sponsors or Stripe.
SaaSyBase is a complete SaaS foundation — not a starter template. It gives you everything a real SaaS product needs out of the box: authentication, subscription billing with four payment providers, a token/credit system, team/organization support, an admin dashboard, email templates, a blog CMS, support tickets, and more.
Who is it for?
- Professional developers who want a battle-tested architecture to build on, with clean abstractions, 500+ unit tests, and production-hardened patterns.
- Vibecoders and AI-assisted builders (Cursor, Lovable, Windsurf, etc.) who want a working SaaS backend they can scaffold their app into without building billing, auth, and admin from scratch.
You plug in your own product logic — SaaSyBase handles the business infrastructure.
- Sponsor SaaSyBase
- Tech Stack
- Project Structure
- Quick Start
- Core Scripts
- Authentication
- Admin Setup
- Payment Providers
- Token System
- Team Plans & Organizations
- Feature Gating
- Coupon System
- Blog & CMS
- Site Pages
- SEO & Discoverability
- Theming & Branding
- Email Templates
- Notifications
- Support Tickets
- Contact Page
- Invoice & Refund Receipts
- Webhooks
- Cron Jobs & Expiry Automation
- File & Logo Storage (S3)
- Analytics (Google Analytics 4)
- Visit Tracking
- Maintenance Mode
- Session Activity
- Moderator Roles
- Rate Limiting
- Logging & Audit Trail
- Security
- Dark Mode
- Testing
- Admin Dashboard Overview
- User Dashboard Overview
- Production Setup
- Self-hosted Deployments
- Environment Variable Reference
- Demo Read-Only Mode
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router) |
| Auth | Clerk or NextAuth (Auth.js v5) — switchable via AUTH_PROVIDER |
| Payment | Stripe, Paystack, Paddle, Razorpay — switchable via PAYMENT_PROVIDER |
| Database | Prisma 7 ORM · PostgreSQL migration baseline · see the database guide for provider-switching rules |
| Styling | Tailwind CSS |
| Rich Text Editor | TipTap (blog posts and editable site pages) |
Nodemailer (SMTP) or Resend, switchable via EMAIL_PROVIDER |
|
| Analytics | Google Analytics 4 (via Data API) + built-in visit tracking |
| PDF Generation | pdf-lib (invoices, refund receipts) |
| Validation | Zod |
| Testing | Vitest (unit/integration) + manual regression checks |
| Monitoring | /api/health endpoint |
A quick map of where things live — useful whether you're browsing the code yourself or pointing an AI agent at it.
saasybase/
├── app/ # Next.js App Router pages and API routes
│ ├── admin/ # Admin dashboard pages (users, plans, blog, etc.)
│ ├── api/ # API routes (webhooks, checkout, internal, etc.)
│ ├── dashboard/ # User dashboard pages (billing, team, profile, etc.)
│ ├── blog/ # Public blog routes
│ ├── pricing/ # Public pricing page
│ ├── contact/ # Public contact page
│ ├── sign-in/ & sign-up/ # Auth pages
│ └── layout.tsx # Root layout (theme injection, auth provider)
├── components/ # React components
│ ├── ui/ # Reusable primitives (Modal, Toast, Pagination, etc.)
│ ├── admin/ # Admin-specific components
│ ├── dashboard/ # Dashboard-specific components
│ ├── billing/ # Checkout & billing components
│ ├── blog/ # Blog display components
│ └── team/ # Team/org management components
├── lib/ # Core business logic
│ ├── auth-provider/ # Auth abstraction layer (Clerk / NextAuth / Better Auth)
│ ├── payment/ # Payment abstraction layer (Stripe / Paystack / Paddle / Razorpay)
│ │ ├── providers/ # Individual provider implementations
│ │ ├── types.ts # PaymentProvider interface
│ │ ├── service.ts # Payment service orchestration
│ │ └── webhook-router.ts # Unified webhook routing
│ ├── email.ts # Email sending (Nodemailer / Resend)
│ ├── email-templates.ts # 27 built-in email templates
│ ├── settings.ts # Admin settings system (50+ keys)
│ ├── features.ts # Feature gating registry
│ └── ... # Tokens, teams, coupons, notifications, etc.
├── prisma/
│ ├── schema.prisma # Database schema (25+ models)
│ └── seed.ts # Database seeding script
├── scripts/ # Operational scripts (backfills, admin tools)
├── tests/ # 500+ Vitest unit/integration tests across 140+ files
├── docs/ # Implementation guides and internal notes
├── ops/ # Production operations (indexes, runbooks)
└── .env.example # Full environment variable template
The repo uses Next.js route groups to keep admin and dashboard layouts isolated without changing the browser URL.
- Files under
app/admin/(valid)/...are served under/admin/.... - Files under
app/dashboard/(valid)/...are served under/dashboard/.... app/admin/[...slug]/page.tsxandapp/dashboard/[...slug]/page.tsxexist as fallback catch-all routes, so the filesystem is intentionally more complex than the public URL structure.
Examples:
app/admin/(valid)/theme/page.tsxrenders/admin/themeapp/dashboard/(valid)/team/page.tsxrenders/dashboard/teamapp/admin/(valid)/logs/page.tsxrenders/admin/logsapp/docs/api/page.tsxrenders/docs/api
Tip for vibecoders: Most of your custom app code will go in
app/dashboard/(user-facing pages),components/(UI), andlib/(business logic). The payment, auth, and admin infrastructure is already built — you're extending it, not rebuilding it.
If you work with GitHub Copilot, Cursor, Claude Code, Windsurf, or another coding agent, read these repo-root files early:
AGENTS.md— specialized AI agent personas and their codebase focus areasCLAUDE.md— project rules, architecture notes, quick reference, and common pitfallsINSTRUCTIONS.md— additional implementation guidance and conventions
These files explain project-specific requirements like using the auth/payment abstractions, checking both generic and legacy provider ID fields, and running the right regression tests for core infrastructure changes.
Requires: Node.js
^20.19.0,^22.12.0, or>=24.0.0, plus npm.
Node 18 is no longer supported. The current stack depends on Next.js 16 and Prisma 7, and Prisma 7 sets the effective minimum runtime floor.
# 1. Copy env template
cp .env.example .env.local
# 2. Install dependencies
npm install
# 3. Point DATABASE_URL at PostgreSQL and apply the committed migration history
npm run prisma:deploy
# 4. Seed the database (prompts for admin email/password)
npx prisma db seed
# 5. Start dev server
npm run devThe committed Prisma migration history in this repo is now PostgreSQL-only. DATABASE_URL selects which PostgreSQL database Prisma targets, but it does not switch the Prisma connector itself, and Prisma 7 does not allow provider = env("DATABASE_PROVIDER") in schema.prisma.
If npm run prisma:deploy reports an old failed migration on what you expected to be a fresh install, check which DATABASE_URL Prisma actually resolved. .env.local, .env.development, .env, or an enabled secrets provider such as Doppler or Infisical can still override your intended target and point Prisma at an older database.
If the issue persists, you should use npx prisma migrate reset (only if you're sure you're targeting a fresh database or a db you wouldn't mind resetting).
If you are moving from an older SQLite-based local setup to PostgreSQL and hit P3019, use the recovery flow in docs/prisma-provider-migrations.md.
You have three normal paths:
- Local PostgreSQL, recommended: run PostgreSQL on your own machine with the official installer or the official Docker image.
- Hosted PostgreSQL, easiest production path: use any managed provider that gives you a normal PostgreSQL connection string.
- Local SQLite, separate local-only lane: fine for throwaway prototyping, but it is not compatible with the committed PostgreSQL migration history in this repo. Do not expect SQLite migrations or
dev.dbfiles to deploy cleanly to PostgreSQL later.
Useful official docs and provider guides:
- Postgres.app for macOS: https://postgresapp.com/
- EDB PostgreSQL Interactive Installer: https://www.enterprisedb.com/downloads/postgres-postgresql-downloads
- pgAdmin downloads: https://www.pgadmin.org/download/
- PostgreSQL downloads: https://www.postgresql.org/download/
- PostgreSQL Docker image: https://hub.docker.com/_/postgres
- Neon docs: https://neon.com/docs
- Supabase database docs: https://supabase.com/docs/guides/database
- Railway PostgreSQL docs: https://docs.railway.com/guides/postgresql
- Render PostgreSQL docs: https://render.com/docs/postgresql
If you want the easiest native local setup:
- macOS: Postgres.app is usually the fastest path.
- Windows/macOS/Linux: the official interactive PostgreSQL installer from EDB is a straightforward guided setup.
- GUI management: pgAdmin is useful for creating databases and inspecting schema state, but it is not the PostgreSQL server itself.
Typical local PostgreSQL shape:
DATABASE_URL="postgresql://USER:PASSWORD@localhost:5432/DBNAME?schema=public"Smallest SQLite-only prototype path:
DATABASE_URL="file:./dev.db"Typical hosted PostgreSQL shape:
DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/DBNAME?schema=public"After running npm run dev, open http://localhost:3000. You'll see the landing page. Sign in at /sign-in or go to /admin once you've set up an admin user (see Admin Setup).
If you access local development through a custom hostname or tunnel instead of plain localhost, set ALLOWED_DEV_ORIGINS in .env.local. Example: ALLOWED_DEV_ORIGINS="app.localhost.test,*.ngrok-free.dev". Leave it empty for normal localhost development, and restart the dev server after changing it.
With Prisma 7, seeding only runs when you explicitly invoke npx prisma db seed; prisma generate, prisma migrate dev, and prisma migrate reset no longer trigger it automatically. When you run npx prisma db seed in an interactive terminal, the seed script prompts for the initial admin email and password instead of always using a hardcoded default. To skip admin creation explicitly, run npx prisma db seed -- --skip-admin. For CI or other non-interactive environments, set SEED_ADMIN_PASSWORD and optionally SEED_ADMIN_EMAIL to create the admin without a prompt. Do not rely on the built-in fallback seed admin credentials in any environment you care about: they are predictable bootstrap defaults and must be replaced immediately.
If you want the shipped admin dashboards, user dashboard, and long lists to look populated during local development, run npm run demo:seed after the normal bootstrap seed. That script inserts demo-namespaced sample users, payments, organizations, tickets, notifications, and blog content so you can inspect the UI with realistic data. Treat it as a local-only sandbox helper, not as staging or production seed data.
Database note: the shared Prisma schema and committed migration history are PostgreSQL. Use PostgreSQL for any workflow that relies on
npm run prisma:deployor committed migrations. If you still prototype with SQLite locally, treat that as a separate disposable lane and do not carry its migrations into production.
| Script | Description |
|---|---|
npm run dev |
Standard dev server (webpack) |
npm run dev:turbo |
Dev server with Turbopack (faster cold starts) |
npm run dev:full |
Dev server + Stripe CLI listener in parallel |
Env validation: A
validate-env.jsscript runs automatically beforedevandbuildvia npm predev/prebuild hooks. It checks for required variables and logs warnings for missing optional ones.
These are the commands most people actually need when working on or operating the project.
| Command | When to use it |
|---|---|
npm run dev |
Start the local app |
npm run dev:turbo |
Faster local startup with Turbopack |
npm run build |
Production build |
npm run start |
Run the production build locally |
npm run typecheck |
Validate TypeScript before a deploy or PR |
npm run lint |
Run ESLint |
npm test |
Run Vitest unit/integration tests |
npm run prisma:studio |
Open Prisma Studio using the repo's Prisma config |
npm run prisma:migrate |
Create and apply a new PostgreSQL Prisma migration when you change the schema |
npm run prisma:deploy |
Apply the committed PostgreSQL migration history to a fresh or deployed database |
npm run prisma:diagnose-provider |
Print the resolved DATABASE_URL provider and the schema provider before you migrate |
npm run secrets:doctor |
Run the provider command directly and report the detected output shape before boot |
npm run backfill:team-subscription-org-links |
Repair legacy organization/subscription links |
npm run prisma:migrate and npm run prisma:deploy explicitly pass --config prisma.config.ts, so Prisma CLI commands read the same env precedence as the app (.env.local → .env.development → .env). If you have a secrets provider enabled, it can still fill missing values, so confirm the resolved DATABASE_URL before migrating.
If you are new to the repo, the normal local loop is: npm install → set a PostgreSQL DATABASE_URL → npm run prisma:diagnose-provider → npm run prisma:deploy → npx prisma db seed → npm run dev.
The app ships with three fully implemented auth providers. Switch between them using the AUTH_PROVIDER environment variable.
Auth switching boundary: Clerk is still a separate migration lane because identities live in Clerk's cloud. NextAuth and Better Auth now share the repo's self-hosted Prisma auth lane, so you can switch between those two without exporting/importing user data. The practical caveat is session continuity: depending on the current rows and cookies in play, a provider switch can still force users to sign in again even when the user/account data is already compatible.
# .env.local
AUTH_PROVIDER="betterauth" # Options: "clerk", "nextauth", "betterauth"Default behavior: The
.env.exampletemplate ships withAUTH_PROVIDER="betterauth"so you can start locally without any third-party accounts. The code's internal fallback is alsobetterauthif the variable is unset, so new setups land on the preferred self-hosted lane by default.
next.config.mjs automatically exposes this as NEXT_PUBLIC_AUTH_PROVIDER to the client bundle so that the auth abstraction layer (lib/auth-provider) can DCE (dead-code eliminate) the unused provider at build time.
The abstraction layer (lib/auth-provider/) defines a full AuthProvider interface with feature detection, mirroring the payment provider pattern. Every method (session, user management, organizations, webhooks, middleware) is provider-agnostic — the rest of the codebase never imports vendor-specific modules directly.
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=""
CLERK_SECRET_KEY=""
CLERK_WEBHOOK_SECRET="" # Required for webhook-driven user init and welcome emails- UI components (
<AuthSignIn>,<AuthSignUp>,<AuthLoaded>,<AuthLoading>, etc.) are re-exported fromlib/auth-provider/client/componentsand switch automatically. - Clerk's
ClerkProviderwraps the app incomponents/AppAuthProvider.tsxwith automatic dark mode theming via aMutationObserveron the<html>class. - Clerk organizations are synced to the local DB via webhooks, while Better Auth and NextAuth use the self-hosted organization lane exposed through the auth abstraction.
AUTH_PROVIDER="betterauth"
BETTER_AUTH_URL="http://localhost:3000"
NEXT_PUBLIC_BETTER_AUTH_URL="http://localhost:3000"
BETTER_AUTH_SECRET="" # Generate with: npx auth secret
AUTH_SECRET="" # Keep aligned with BETTER_AUTH_SECRET
NEXTAUTH_SECRET="" # Optional compatibility fallback
# Optional OAuth providers:
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""Better Auth supports local credentials, GitHub OAuth, Google OAuth, magic links, and app-managed organizations while still routing through the same lib/auth-provider/ abstraction as Clerk and NextAuth. It is the preferred self-hosted provider lane when you want built-in organization primitives without depending on Clerk.
Better Auth and NextAuth are intentionally kept on a shared self-hosted data lane in this repo. User rows, credential hashes, OAuth account mappings, and verification-state compatibility are normalized so that switching between AUTH_PROVIDER="nextauth" and AUTH_PROVIDER="betterauth" does not require a separate user-data migration.
- Go to GitHub Developer Settings → OAuth Apps.
- Create a new OAuth App.
- Set the Homepage URL to your app base URL.
- Set the Authorization callback URL to:
- Local:
http://localhost:3000/api/auth/callback/github - Production:
https://your-domain.com/api/auth/callback/github
- Copy the generated Client ID and Client Secret into:
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""- Select or Create a Project: Go to the Google Cloud Console. Click the project dropdown in the top navigation bar and select an existing project or click "New Project" to create one dedicated to your application.
- Configure OAuth Consent Screen: Navigate to APIs & Services → OAuth consent screen (or Google Auth Platform). Choose External user type, then fill in your App Name, support email, and developer contact information. Complete the setup wizard.
- Create OAuth Client: From the Google Auth Platform Overview, click Create OAuth client (or navigate to Clients on the left menu and click Create). Choose Web application as the Application type and give it a name (e.g., "SaaSyBase Login").
- Add Authorized JavaScript origins: Under that section, click "Add URI".
- Local:
http://localhost:3000 - Production:
https://your-domain.com
- Add Authorized redirect URIs: Under that section, click "Add URI".
- Local:
http://localhost:3000/api/auth/callback/google - Production:
https://your-domain.com/api/auth/callback/google
- Click Create. A modal will appear with your Client ID and Client Secret. Copy them into your
.env.localfile:
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""- Keep
AUTH_PROVIDER="betterauth". - Keep
BETTER_AUTH_URL,NEXT_PUBLIC_BETTER_AUTH_URL, andNEXT_PUBLIC_APP_URLaligned with the exact base URL you registered with GitHub and Google. - The sign-in and sign-up UI only shows GitHub and Google buttons when the corresponding provider is fully configured.
- Leaving the GitHub or Google env vars blank disables that provider cleanly without breaking credentials or magic-link auth.
AUTH_SECRET="" # Generate with: npx auth secret
# Optional OAuth providers:
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""NextAuth supports credentials (email + password), GitHub OAuth, Google OAuth, and passwordless email magic links out of the box — enable the ones you need in lib/nextauth.config.ts. Transactional app email can use either Nodemailer or Resend via EMAIL_PROVIDER; the NextAuth email-login flow itself is SMTP-based.
- Go to GitHub Developer Settings → OAuth Apps.
- Create a new OAuth App.
- Set the Homepage URL to your app base URL.
- Set the Authorization callback URL to:
- Local:
http://localhost:3000/api/auth/callback/github - Production:
https://your-domain.com/api/auth/callback/github
- Copy the generated Client ID and Client Secret into:
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""- Select or Create a Project: Go to the Google Cloud Console. Click the project dropdown in the top navigation bar and select an existing project or click "New Project" to create one dedicated to your application.
- Configure OAuth Consent Screen: Navigate to APIs & Services → OAuth consent screen (or Google Auth Platform). Choose External user type, then fill in your App Name, support email, and developer contact information. Complete the setup wizard.
- Create OAuth Client: From the Google Auth Platform Overview, click Create OAuth client (or navigate to Clients on the left menu and click Create). Choose Web application as the Application type and give it a name (e.g., "SaaSyBase Login").
- Add Authorized JavaScript origins: Under that section, click "Add URI".
- Local:
http://localhost:3000 - Production:
https://your-domain.com
- Add Authorized redirect URIs: Under that section, click "Add URI".
- Local:
http://localhost:3000/api/auth/callback/google - Production:
https://your-domain.com/api/auth/callback/google
- Click Create. A modal will appear with your Client ID and Client Secret. Copy them into your
.env.localfile:
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""- Keep
AUTH_PROVIDER="nextauth". - Keep
NEXT_PUBLIC_APP_URLandNEXTAUTH_URLaligned with the exact base URL you registered with GitHub and Google. - GitHub and Google are only registered when both env vars for that provider are present.
- The sign-in and sign-up UI only shows the GitHub and Google buttons when NextAuth reports those providers as configured.
- Leaving the GitHub or Google env vars blank disables that provider cleanly without breaking credentials or magic-link auth.
Key differences from Clerk:
- Users are stored entirely in your own DB (Prisma adapter).
- Organization primitives stay primarily app-managed in the NextAuth lane, without external provider sync.
- Email verification uses an in-app pending-change flow (
lib/nextauth-email-verification.ts).
- Create the initial admin during
npx prisma db seed, or promote an existing local user in Prisma Studio. - If promoting manually, set the user's
roletoADMINin theUsertable. - Sign in again and verify
/adminloads.
For a fresh local database, run npx prisma db seed to create the initial admin user during setup. The seed script prompts for the admin email and password in an interactive terminal, and it also supports non-interactive creation with SEED_ADMIN_EMAIL and SEED_ADMIN_PASSWORD. If a non-interactive run ever falls back to the built-in default seed admin credentials, change that email and password immediately before exposing the app anywhere outside a disposable local sandbox.
Option 1 — Direct SQL (most secure)
Run this in a PostgreSQL client such as psql or your database provider's SQL console. Do not paste raw SQL into a normal shell prompt.
psql "$DATABASE_URL" -c "UPDATE \"User\" SET role = 'ADMIN' WHERE id = 'user_xxxxxxxxxxxxx';"UPDATE "User" SET role = 'ADMIN' WHERE id = 'user_xxxxxxxxxxxxx';Option 2 — Seed the first admin user
For a fresh production database, npx prisma db seed also lets you set up the initial admin user before opening the app to real traffic.
💡 With Clerk, you can find the provider user ID in the Clerk dashboard. With either auth provider, you can inspect the local DB via
npm run prisma:studio.
Select the active provider:
PAYMENT_PROVIDER="stripe" # Options: "stripe", "paystack", "paddle", "razorpay"All providers share a common checkout → webhook → subscription lifecycle. The app routes new transactions to the active provider; existing transactions are handled by the provider recorded in their paymentProvider field.
Note: A Lemon Squeezy provider implementation exists in
lib/payment/providers/lemonsqueezy.tsbut is archived and not wired into the active provider registry. It is kept for reference only.
Seeded plans automatically receive provider-generated price IDs during npx prisma db seed when catalog auto-create is enabled for the active payment provider. You do not need to hand-maintain plan ID env vars for the shipped plans.
If you create new plans later, the same provider sync path can populate their IDs into the database.
Plans support per-provider localized pricing via the PlanPrice model. This allows different prices in different currencies for each payment provider:
PlanPrice {
planId, provider, currency, amountCents, externalPriceId
}
For example, a plan can have a $10 USD price on Stripe and a ₦15,000 NGN price on Paystack simultaneously.
Current UI reality: the shipped admin plan modal does not yet expose full PlanPrice CRUD management. It supports the base plan fields, recurring cadence, token/team metadata, and advanced external provider price ID overrides. If you need full localized price-row management today, use seed/sync flows, Prisma Studio, direct data tooling, or build additional admin UI.
PAYMENT_AUTO_CREATE="true"When enabled, saving a plan without a provider price ID will auto-create catalog objects for configured payment providers where supported.
In the admin plan creation modal, the manual provider price ID field is intentionally treated as an advanced override and appears at the bottom of the form. Leave it blank for the normal flow. Fill it only when you are importing an existing provider catalog entry, migrating legacy plans, or operating with auto-create disabled.
Admin plans support recurringInterval (day, week, month, year) and recurringIntervalCount (cadence multiplier, e.g. month + 2 = billed every 2 months) when autoRenew is enabled.
Razorpay constraint: Daily subscriptions require
recurringIntervalCount >= 7. A warning is logged and Razorpay price creation is skipped for shorter intervals while other providers continue to work.
STRIPE_SECRET_KEY="sk_live_..."
STRIPE_WEBHOOK_SECRET="whsec_..." # Supports comma-separated for rotation
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_live_..."Webhook endpoint: /api/webhooks/payments (centralized, preferred), /api/webhooks/stripe, or /api/stripe/webhook
Local testing:
stripe listen --forward-to localhost:3000/api/stripe/webhookCopy the printed secret into STRIPE_WEBHOOK_SECRET.
Stripe Customer Portal: Enable it in Stripe Dashboard → Settings → Billing → Customer Portal. Without this the "Manage payment" button returns an error.
Recommended webhook events:
checkout.session.completedcheckout.session.async_payment_succeededinvoice.payment_succeededinvoice.payment_failedinvoice.upcoming(renewal reminder emails)customer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedcharge.refunded(optional)charge.dispute.created/charge.dispute.updated/charge.dispute.closed(optional)
The app normalizes checkout.session.async_payment_succeeded to checkout.completed and payment_intent.succeeded (when attached to an invoice) to invoice.payment_succeeded so async and non-invoice flows are handled consistently.
PAYSTACK_SECRET_KEY="sk_live_..."
PAYSTACK_WEBHOOK_SECRET="" # Optional — falls back to PAYSTACK_SECRET_KEY
NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY="pk_live_..."Webhook endpoint: /api/webhooks/payments (centralized ingress, preferred) or /api/webhooks/paystack
Pricing model: Paystack uses plan_code as the price ID. One-time payments pass the raw amount; subscriptions pass the plan_code to /transaction/initialize.
Default currency: NGN. Supported currencies: NGN, GHS, ZAR, KES, USD (USD requires merchant approval; set PAYSTACK_CURRENCY=USD to use it as default).
Cancel at period end (workaround): Paystack has no native cancel-at-period-end. The app implements a workaround:
- Sets
cancelAtPeriodEnd = truein the DB on cancel. - On
invoice.created, cancels in Paystack before the charge fires if the flag is set.
Recommended webhook events:
charge.success(required)subscription.createsubscription.not_renew(cancel-at-period-end signal)subscription.disableinvoice.create(cancel-at-period-end workaround)invoice.updateinvoice.payment_failedrefund.processed
refund.pendingis intentionally a no-op to avoid premature refund marking.
PADDLE_API_KEY="pat_live_..."
PADDLE_WEBHOOK_SECRET="..." # Notification destination secret; supports comma-separated rotation
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN="..." # Paddle.js client token
# Sandbox / live selection:
PADDLE_ENV="sandbox"
NEXT_PUBLIC_PADDLE_ENV="sandbox"
# Only needed when provider-side catalog sync is enabled
PAYMENT_AUTO_CREATE="true"
PADDLE_DEFAULT_TAX_CATEGORY="standard" # Required for auto-creation of products/pricesThe minimal Paddle setup is five values: PADDLE_API_KEY, PADDLE_WEBHOOK_SECRET, NEXT_PUBLIC_PADDLE_CLIENT_TOKEN, PADDLE_ENV, and NEXT_PUBLIC_PADDLE_ENV. Keep PAYMENT_AUTO_CREATE, PADDLE_DEFAULT_TAX_CATEGORY, PADDLE_CURRENCY, PADDLE_WEBHOOK_TOLERANCE_SECONDS, and PADDLE_DEBUG_SUBSCRIPTION_UPDATES for advanced or provider-sync-specific cases.
Webhook endpoint: /api/webhooks/payments (preferred) or /api/webhooks/paddle
Default Payment Link (required):
Paddle requires a Default payment link to generate transaction.checkout.url. The app provides a ready-made page at /paddle/pay.
- In Paddle Dashboard → Checkout → Settings, set Default payment link to:
https://YOUR_DOMAIN/paddle/pay - The domain must be an approved website in Paddle (Paddle → Checkout → Website approval).
- For local development, use an HTTPS tunnel (e.g. ngrok) and register your ngrok URL.
This is a redirect-only integration. Treat webhooks as the source of truth for granting access.
Admin config check: If you see a generic Paddle overlay error, call GET /api/admin/billing/paddle-config to detect missing Default payment link, missing prices, or invalid credentials.
Recommended webhook events:
transaction.completed(required)subscription.createdsubscription.updatedtransaction.payment_failed(optional)adjustment.created(refunds, auto-approved)adjustment.updated(refunds requiring approval)
RAZORPAY_KEY_ID=""
RAZORPAY_KEY_SECRET=""
RAZORPAY_WEBHOOK_SECRET=""
NEXT_PUBLIC_RAZORPAY_KEY_ID=""
RAZORPAY_CURRENCY="USD" # Optional; affects catalog sync and redirect checkouts (default: INR)
RAZORPAY_ENABLE_OFFERS="true" # Optional; attach offer_id to one-time Payment LinksWebhook endpoint: /api/webhooks/payments (centralized ingress only; no dedicated Razorpay alias route is currently shipped)
Checkouts are redirect-based:
- One-time: Payment Links (
/v1/payment_links) →short_url - Subscriptions: Subscriptions API (
/v1/subscriptions) →short_url
Manage payment: Uses the subscription's hosted short_url as a best-effort management page. No Stripe-style customer portal exists.
Recommended webhook events:
payment_link.paid(required)payment.capturedpayment.failedrefund.processedpayment.refundedsubscription.activated(if using Subscriptions)subscription.updatedsubscription.cancelledsubscription.halted
Optional: Razorpay Offers ↔ app coupons (one-time only)
Set RAZORPAY_ENABLE_OFFERS=true and embed an offer ID in the coupon's description field:
razorpayOfferId=offer_ABC123
If Razorpay rejects the offer_id, the server retries without it.
| Feature | Stripe | Paystack | Paddle | Razorpay |
|---|---|---|---|---|
| Coupons | ✅ Provider | ✅ In-app only | ✅ Provider | ✅ In-app only (offers opt-in) |
| Proration | ✅ | ❌ | ✅ | ✅ |
| Subscription updates | ✅ | ❌ (cancel + recreate) | ✅ | ✅ |
| Cancel at period end | ✅ | ✅ (workaround) | ✅ | ✅ |
| Manage payment experience | ✅ Hosted portal | Subscription short_url best effort | ✅ Hosted portal | Subscription short_url best effort |
| Invoices / Receipts | ✅ | ❌ | ❌ | ❌ |
| PDF Invoices (in-app) | ✅ | ✅ | ✅ | ✅ |
| Refunds | ✅ | ✅ | ✅ | ✅ |
| Disputes | ✅ | ❌ | ❌ | ❌ |
| Inline elements | ✅ | ✅ | ❌ | ❌ |
| Trial periods | ✅ | ❌ | ❌ | ❌ |
| Default currency | USD | NGN | USD | INR |
PDF invoices/receipts are generated in-app via
pdf-libfor all providers, regardless of native invoicing support. See Invoice & Refund Receipts.
The app resolves the active currency in this order:
| Priority | Variable | Scope |
|---|---|---|
| 1 | Provider-specific: PADDLE_CURRENCY, PAYSTACK_CURRENCY, RAZORPAY_CURRENCY |
Per-provider override for multi-provider deployments |
| 2 | Admin setting: DEFAULT_CURRENCY |
DB-backed default, set in admin settings |
| 3 | PAYMENTS_CURRENCY |
Environment fallback when no admin default is set |
| 4 | Provider default | NGN for Paystack, INR for Razorpay, USD for Stripe/Paddle |
NEXT_PUBLIC_CURRENCY and STRIPE_CURRENCY are no longer part of the runtime currency resolver.
The schema uses provider-neutral fields plus JSON maps for multi-provider support:
- Generic columns (
externalSubscriptionId,externalPriceId,paymentProvider) — used for all new transactions. - Provider ID maps (
externalSubscriptionIds,externalPriceIdsas JSON) — for multi-provider per-record support. - Compatibility aliases — some admin/API inputs still accept Stripe-named aliases like
stripePriceIdfor convenience, but the database model itself is provider-neutral.
When querying, always resolve subscriptions and plans the same way the service layer does: check the direct column first, then fall back to the provider-ID JSON map helpers in lib/payment/service.ts and lib/plans.ts.
See the public docs page at /docs/adding-payment-provider for the overview, and docs/adding-payment-providers.md for the deeper implementation guide.
npm run public:export builds a sanitized tree in dist/public-export from tracked files plus the exclusions in public-export.config.json.
The public export intentionally omits:
- internal planning notes like
docs/better-auth-migration-feasibility.md - one-off/private migration helpers like
scripts/backfill-better-auth-coexistence.ts - operational runbooks, public-history tooling, generated docs-search artifacts, local DB files, and the in-app docs source under
app/docs/
Public implementation guides that are useful to downstream users, such as docs/adding-payment-providers.md, remain included.
The public export now keeps scripts/seed-demo.ts, so downstream users can run npm run demo:seed locally if they want to inspect the shipped admin/dashboard UI with populated sample data. That helper is for local development only and should not be used to seed staging or production environments.
The app ships with a multi-bucket token system for metering usage:
| Bucket | Field | Purpose |
|---|---|---|
| Paid personal tokens | User.tokenBalance |
Granted by plan purchases and top-ups. Configurable expiry and renewal behavior. |
| Free-plan tokens | User.freeTokenBalance |
Granted by the free plan using configurable renewal rules. |
| Organization shared balance | Organization.tokenBalance |
Shared workspace pool used by SHARED_FOR_ORG organizations. |
| Organization member allocation | OrganizationMembership.sharedTokenBalance |
Per-member workspace balance used by ALLOCATED_PER_MEMBER organizations. |
Internal API endpoint (protected by INTERNAL_API_TOKEN):
POST /api/internal/spend-tokens — Deducts tokens. Accepts a bucket parameter:
| Bucket | Behavior |
|---|---|
auto |
Context-aware selection: shared-only in organization context, or paid-then-free in personal context |
paid |
Only deducts from paid tokens |
free |
Only deducts from free tokens |
shared |
Deducts from the active organization context |
There is also a first-party user route at POST /api/user/spend-tokens with the same bucket semantics for authenticated product flows.
In practice, auto now follows the active workspace boundary instead of blending balances across contexts:
- In an organization workspace,
autospends from shared workspace balance only. - In a personal workspace,
autospends from paid first and then free. autonever silently falls back from a workspace request into personal paid/free balance, and it never reaches into shared workspace balance from a personal workspace.
When a user belongs to a team plan, the organization has its own token system:
| Field | Purpose |
|---|---|
Organization.tokenBalance |
Shared workspace balance used by SHARED_FOR_ORG workspaces |
Organization.tokenPoolStrategy |
Workspace token mode: SHARED_FOR_ORG or ALLOCATED_PER_MEMBER |
Organization.memberTokenCap |
Optional per-member limit for shared-pool workspaces |
Organization.memberCapStrategy |
SOFT, HARD, or DISABLED enforcement for shared-pool workspaces |
Organization.memberCapResetIntervalHours |
How often shared-pool member usage windows reset |
OrganizationMembership.sharedTokenBalance |
Per-member allocated balance used by ALLOCATED_PER_MEMBER workspaces |
OrganizationMembership.memberTokenUsage |
Per-member usage tracking within the window |
OrganizationMembership.memberTokenCapOverride |
Per-member cap exception |
SaaSyBase now supports two distinct team token strategies:
SHARED_FOR_ORG: the workspace has one shared balance and optional per-member cap rules.ALLOCATED_PER_MEMBER: each active member gets their own balance onOrganizationMembership.sharedTokenBalance, and shared-pool cap management is hidden in the dashboard because it does not apply.
Token grants, renewals, one-time top-ups, and pending-activation flows all branch on the effective team strategy.
POST /api/internal/track-visit— Records a visit log entry.POST /api/internal/payment-scripts— Payment-related script operations.
initializeNewUserTokens— allocates starter tokens on first user creation.resetUserTokensIfNeeded— resets free tokens monthly (checked on every dashboard visit).shouldResetPaidTokensOnExpiry/shouldResetPaidTokensOnRenewal— configurable in admin settings.
The admin settings surface also exposes the paid-token operations controls under /admin/settings:
| Setting | Purpose |
|---|---|
FREE_PLAN_TOKEN_LIMIT |
Amount granted to free-plan users |
FREE_PLAN_RENEWAL_TYPE |
Renewal cadence for free-plan balance (daily, monthly, one-time, unlimited) |
FREE_PLAN_TOKEN_NAME |
Optional free-plan-specific token label |
TOKENS_RESET_ON_EXPIRY_ONE_TIME |
Reset paid tokens when a one-time plan expires |
TOKENS_RESET_ON_EXPIRY_RECURRING |
Reset paid tokens when a recurring plan naturally expires |
TOKENS_RESET_ON_RENEWAL_ONE_TIME |
Reset paid tokens on one-time renewal-style flows |
TOKENS_RESET_ON_RENEWAL_RECURRING |
Reset paid tokens on recurring renewals |
TOKENS_NATURAL_EXPIRY_GRACE_HOURS |
Grace window before natural-expiry cleanup clears paid tokens and applies the organization access cleanup policy |
ORGANIZATION_EXPIRY_MODE |
Expiry policy for organization access: suspend workspaces by default or dismantle them explicitly |
ORGANIZATION_EXPIRY_MODE currently supports two operational modes:
SUSPEND— preserve the local workspace record, expire invites, remove provider-side access when applicable, and show an in-dashboard suspension notice.DISMANTLE— fully tear down the workspace and related access instead of leaving it in a recoverable suspended state.
Team subscriptions provision managed organizations and keep them in sync with billing status.
- Provisioning: When a qualifying subscription activates,
ensureTeamOrganizationcreates or updates an organization, assigns a deterministic slug, and mirrors metadata to the active auth provider when that provider supports organization primitives. In practice, that means an active team plan whose plan hassupportsOrganizations: trueand is not in a proration-pending state. - Token strategies: Team plans can use either a shared workspace pool or allocated-per-member balances. The effective dashboard strategy follows the attached team plan so older organizations with legacy defaults still render correctly.
- Member entitlements: When a workspace uses
ALLOCATED_PER_MEMBER, joining members receive the plan token allowance in their membership balance, renewals reset those balances, and top-ups/extensions credit each active member instead of the org pool. - Cleanup:
syncOrganizationEligibilityForUserruns whenever subscription status changes (checkout, activation, webhook, admin override). When a plan lapses beyond the grace window, the helper suspends workspace access by default, clears member access, and can be switched to full dismantling throughORGANIZATION_EXPIRY_MODE. - Admin intervention: admins can explicitly suspend and later restore an organization from
/admin/organizations. Suspended workspaces keep their local record, show a workspace notice in the dashboard, and use the owner email as the billing-contact fallback when no dedicated billing email is stored. - Dashboard:
/dashboard/teamhosts the management UI with invites, member removal, provisioning refresh, strategy-aware balance labels, and shared-pool cap controls that only appear when the workspace actually usesSHARED_FOR_ORG. - Invite acceptance:
/invite/[token]— token-based invite acceptance page for new and existing users. - API routes:
/api/team/invite,/api/team/invite/revoke,/api/team/members/remove,/api/team/summary,/api/team/provision,/api/team/settings. - Clerk webhook sync:
organization.*,organizationMembership.*, andorganizationInvitation.*events are handled in/api/webhooks/clerkto keep Prisma and Clerk in sync.
Users can move between their personal workspace and team workspaces.
- UI switcher: the dashboard sidebar footer renders the auth-provider organization switcher
- App-managed switching:
POST /api/user/active-orgstores or clears the active organization in an httpOnly cookie - Why it matters: billing, plan scope, token spending, checkout metadata, and team pages all follow the active workspace context
If a user accepts a team invite, the client attempts to activate that workspace immediately before navigating them into /dashboard/team.
Plan {
scope String @default("INDIVIDUAL") // "INDIVIDUAL" or "TEAM"
supportsOrganizations Boolean @default(false)
organizationSeatLimit Int?
organizationTokenPoolStrategy String? @default("SHARED_FOR_ORG") // or "ALLOCATED_PER_MEMBER"
minSeats / maxSeats / seatPriceCents // Seat-based pricing
}Define per-feature access in lib/features.ts using the FeatureId enum, then wrap UI in the gate component:
import { FeatureGate } from '@/lib/featureGate';
import { FeatureId } from '@/lib/features';
<FeatureGate feature={FeatureId.WATERMARK_REMOVAL}>
<RemoveWatermarkButton />
</FeatureGate>The gate checks both personal subscriptions and organization access — if the user is an owner or member of an organization with an active team subscription, access is granted even without a personal subscription.
PRO_FEATURES in lib/features.ts lists all gated features (e.g. OFFSET_SETTINGS, EXPORT_SCALE_*, WATERMARK_REMOVAL, SUPPORT_PRIORITY, USAGE_LIMIT_ELEVATED, etc.).
Note for new projects: The shipped
FeatureIdentries (likeWATERMARK_REMOVAL,FOV_ADJUST) are examples from the original product. Replace them with your own feature IDs — the gating system works with any string enum values.
The app includes a full-featured coupon engine with provider-aware discount handling.
- Percent-off and amount-off discounts
- Duration control:
once,repeating(N months), orforever - Plan restrictions via
CouponPlanjoin table — limit coupons to specific plans - Currency-aware: Amount-off coupons can be restricted to a specific currency
- Minimum purchase thresholds (
minimumPurchaseCents) - Max redemptions and time-bound availability (
startsAt/endsAt) - Multi-provider coupon sync: Coupons are auto-synced to providers that support native coupons (Stripe, Paddle); for others (Paystack, Razorpay), discounts are applied in-app
- Admin:
/admin/coupons— create, edit, activate/deactivate coupons, set plan restrictions - User:
/dashboard/coupons— view redeemed coupons and pending redemptions
A full-featured blog is built into the app:
- Public routes:
/blog,/blog/[slug],/blog/category/[category] - Admin CRUD:
/admin/blog(list/create/edit/delete posts and categories) - API routes:
/api/admin/blog/route.ts(bulk),/api/admin/blog/[id],/api/admin/blog/categories - Lib:
lib/blog.ts— paginated queries, category management, slug normalization, and rich-text sanitization vialib/htmlSanitizer.ts. - Components:
components/blog/BlogSidebar.tsx,RelatedPosts.tsx,BlogListingStyles.tsx - Rich text editing: TipTap editor with images, links, colors, text alignment, YouTube embeds, horizontal rules, and more.
Blog posts use a many-to-many category system:
SitePage ←→ BlogPostCategory ←→ BlogCategory
Every blog post and site page includes dedicated SEO fields:
metaTitle,metaDescription,canonicalUrl,noIndex- Open Graph:
ogTitle,ogDescription,ogImage
Blog posts are stored using the SitePage model (shared with editable site pages) and differentiated by collection (page vs blog).
Editable public pages (Terms, Privacy, Refund Policy, etc.) are managed in the admin:
- Admin CRUD:
/admin/pages(list/create/edit) - Public routes:
/terms,/privacy,/refund-policy,/[slug](dynamic catch-all) - Lib:
lib/sitePages.ts— slug normalization, trash/restore, sanitized rich-text content. - Core pages (Terms, Privacy, Refund Policy) are seeded automatically if they don't exist.
Template variables (e.g. {{siteName}}) are interpolated at render time from admin settings.
SaaSyBase ships with an admin-managed SEO system centered on /admin/settings → SEO.
- Homepage metadata: title, description, canonical URL, homepage social title, homepage social description, homepage social image
- Sitewide title composition: a suffix or a full template containing
%s - Global social fallbacks: default Open Graph / Twitter title, description, and image for routes that do not define their own social copy
- Blog discoverability:
/blogtitle and description, blog index no-index, and blog category no-index defaults - Sitemap management: custom same-site URLs plus exact sitemap exclusions
- Verification: Google and Bing verification tokens
- Robots.txt: a dedicated editor for appended custom directives plus a sitewide no-index switch
- Homepage (
/) uses the homepage SEO fields and falls back to the global social defaults when homepage-specific OG values are blank. - Public export homepage uses the same homepage SEO settings as the main root page.
- Blog listing (
/blog) uses the blog listing title and description, can be independently no-indexed, and falls back to the global social defaults. - Blog category pages can be globally no-indexed from the SEO tab.
- Published site pages and blog posts continue to use their own per-entry SEO fields first, with global OG fallbacks filling in missing social values.
- Docs pages keep their docs-specific metadata copy, while still inheriting sitewide title templating, verification tags, and sitewide no-index behavior through the root layout.
/sitemap.xml is generated dynamically and includes:
- the homepage
- the blog listing
- all published site pages
- all published blog posts
- any custom URLs you add in the SEO tab
Exact URLs can also be excluded before the final sitemap is returned.
/robots.txt is also generated dynamically. The core file is not handwritten; it is built from the same SEO settings, then optionally extended with custom directives saved from the robots.txt modal.
When sitewide no-index is enabled:
- the root layout emits
robots: { index: false, follow: false } /robots.txtswitches toDisallow: /- the generated file includes an explicit warning comment so operators can tell at a glance that full-site blocking is active
Canonical URLs, the sitemap host, and robots.txt host lines depend on the configured site URL. That value is resolved from environment configuration in this order:
NEXT_PUBLIC_APP_URLNEXTAUTH_URLVERCEL_PROJECT_PRODUCTION_URLVERCEL_URL- fallback to
http://localhost:3000
If your production host is not set correctly, the generated canonical URLs, sitemap, and robots.txt output will reflect the wrong origin.
Blog posts and editable site pages keep their own SEO fields:
metaTitle,metaDescription,canonicalUrl,noIndexogTitle,ogDescription,ogImage
Use the admin SEO tab for global defaults and cross-site discoverability controls. Use the CMS entry fields when a specific post or page needs custom search or social metadata.
For a fuller operator guide, see /docs/seo-and-discoverability.
The admin theme designer (/admin/theme) provides comprehensive branding control without touching code.
Full light/dark mode color palettes with CSS custom properties generated server-side in app/layout.tsx:
- Core surfaces:
bgPrimary,bgSecondary,bgTertiary,bgQuaternary - Text:
textPrimary,textSecondary,textTertiary - Borders:
borderPrimary,borderSecondary - Accents:
accentPrimary,accentHover - Gradients: Page, hero, card, and tabs gradients (each with
from/via/to) - Special surfaces:
headerBg,stickyHeaderBg,sidebarBg,heroBg,panelBg,pageGlow - Preset palettes: Saved color schemes for quick application
- Header links and footer links — configurable link lists
- Footer text with
{{year}}and{{siteName}}interpolation - Header layout: Blur radius, border width, shadow, font size/weight (for both default and sticky states)
- Custom CSS — injected globally via
<style>tag - Custom
<head>snippet — scripts, meta tags, third-party integrations - Custom
<body>snippet — bottom-of-page scripts (analytics, chat widgets)
- Listing style and page size configuration
- Sidebar settings and related posts toggle
- Blog HTML snippets — inject HTML before/after/between blog posts (ads, CTAs)
- Pricing layout settings — configured through the theme admin
The app includes a full email template CMS with database-backed, editable templates.
/admin/emails — form-based editor for all email templates. Each template supports HTML and plain text versions, activation toggles, test sends, and {{variable}} placeholders.
| Template Key | When Sent |
|---|---|
welcome |
User registers and verifies email |
subscription_activated |
Subscription becomes active |
subscription_extended |
Existing subscription is extended |
subscription_upgraded |
User upgrades from non-recurring to recurring plan |
subscription_upgraded_recurring |
User upgrades between recurring plans |
subscription_upgrade_scheduled_recurring |
Recurring upgrade scheduled for cycle end |
subscription_change_scheduled_recurring |
Plan change scheduled for cycle end |
subscription_downgraded |
User downgrades plan |
subscription_cancelled |
Subscription cancelled |
subscription_expired |
Subscription expired |
subscription_ended |
Subscription fully ended |
subscription_renewed |
Subscription renewed |
subscription_renewal_reminder |
Upcoming renewal reminder (Stripe invoice.upcoming) |
token_topup |
User purchases additional tokens/credits |
tokens_credited |
Admin credits tokens to a user |
tokens_debited |
Admin debits tokens from a user |
admin_assigned_plan |
Admin assigns a plan to a user |
team_invitation |
User is invited to join an organization |
admin_notification |
Admin billing alert emails |
refund_issued |
Refund processed for a payment |
refund_processed |
Refund confirmed by provider |
payment_failed |
Payment attempt failed |
invoice_payment_failed |
Invoice payment failed |
password_reset |
Password reset link (NextAuth) |
email_verification |
Email verification link (NextAuth) |
email_change_confirmation |
Email address change confirmation (NextAuth) |
magic_link |
Magic link sign-in (NextAuth) |
All templates support a common set of variables:
- User:
{{firstName}},{{lastName}},{{fullName}},{{userEmail}} - Billing:
{{planName}},{{amount}},{{transactionId}},{{tokenAmount}} - Site:
{{siteName}},{{supportEmail}},{{siteUrl}},{{siteLogo}} - Branding:
{{accentColor}},{{accentHoverColor}}— resolved from theme palette - Links:
{{dashboardUrl}},{{billingUrl}}
All sent emails are logged in the EmailLog model with recipient, subject, template key, and delivery status.
The app has a full in-app notification system.
| Type | Example |
|---|---|
BILLING |
Subscription renewed, payment failed, refund processed |
SUPPORT |
Admin replied to your ticket |
ACCOUNT |
Profile updated, plan assigned |
TEAM_INVITE |
You've been invited to join a team |
GENERAL |
System announcements |
- In-app notifications with unread badge counts
- Type labels: The app treats
BILLING,SUPPORT,ACCOUNT,TEAM_INVITE, andGENERALas the standard categories, but the database storestypeas a string rather than a hard enum. - URL deep-linking — each notification can link to a specific page
- Deduplication — 5-minute window prevents duplicate notifications
- Paired with email: Billing notifications can optionally trigger an email via the template system
- Admin alerts: Configurable per-event-type admin emails (refund, new purchase, renewal, upgrade, downgrade, payment failure, dispute)
- Global notifications: Admins can send notifications to all users
- User:
/dashboard/notifications— view and mark notifications as read - Admin:
/admin/notifications— manage system notifications - API:
/api/notifications(list),/api/notifications/[id](single),/api/notifications/mark-all-read
A built-in support ticket system for user-admin communication.
- Ticket lifecycle: Open → admin/user replies → resolved
- Reply threads with user and admin messages
- Ticket categories: General, Technical Support, Billing, Pre-Sale, Account, Feature Request
- Email notifications: Configurable per-event (
new_ticket_to_admin,admin_reply_to_user,user_reply_to_admin) - Dashboard badge: Users see a "NEW" badge when an admin has replied
- User:
/dashboard/support— create tickets, view replies - Admin:
/admin/support— view all open tickets, reply, manage status - User APIs:
/api/support/tickets,/api/support/tickets/[ticketId],/api/support/tickets/[ticketId]/reply - Admin APIs:
/api/admin/support/tickets,/api/admin/support/tickets/[ticketId],/api/admin/support/tickets/[ticketId]/reply
The support center is already integrated into the wider product flow: billing pages link users there for refund/help requests, notifications can deep-link back into ticket threads, and support email templates notify the relevant side when a ticket or reply is created.
A public contact form at /contact backed by the ContactForm component and /api/contact API route. The page content is managed as a site page (editable from /admin/pages).
The app generates PDF invoices and refund receipts server-side using pdf-lib, available for all payment providers regardless of native invoicing support.
Generated A4 documents including:
- Site branding (name, logo)
- Invoice number, date, status, payment provider reference
- Bill-to details (customer name, email)
- Service details (plan name, description, duration, service period)
- Coupon/discount breakdown
- Payment summary with subtotal, discount, and total
Similar layout with refund-specific details:
- Refund ID, date, original transaction reference
- Refunded amount vs. original amount
- Coupon applied (if any)
/api/webhooks/payments is the preferred single endpoint for all payment providers. It auto-detects the provider from the signature header:
| Header | Provider |
|---|---|
stripe-signature |
Stripe |
x-paystack-signature |
Paystack |
paddle-signature |
Paddle |
x-razorpay-signature |
Razorpay |
Provider-specific routes also exist as aliases: /api/stripe/webhook (Stripe), /api/webhooks/paystack (Paystack), /api/webhooks/paddle (Paddle).
Stripe also exposes /api/webhooks/stripe as an alias route. Razorpay currently relies on the centralized /api/webhooks/payments route rather than a dedicated provider-specific alias.
/api/webhooks/clerk handles:
user.created— immediately runsensureUserExiststo initialize the user DB record and allocate starting tokens.user.updated— syncs email/verification status; triggers welcome email if verified.organization.*events — upserts organizations from Clerk into the local DB.organizationMembership.*events — syncs member roles and status.organizationInvitation.*events — tracks invite state (pending, accepted, revoked).
Signatures are verified using svix (Clerk's delivery layer). In development, unsigned events are accepted with a warning.
GET /api/cron/process-expiry
Authorization: Bearer <CRON_PROCESS_EXPIRY_TOKEN>
Run this periodically (hourly or daily) to:
- Expire stale ACTIVE subscriptions past their
expiresAt. - Apply the configured organization-expiry policy to "zombie" organizations whose owner's subscription has lapsed. The default is to suspend access instead of dismantling the local workspace record.
- Process the subscription queue for batch operations.
In production, unauthorized requests return 404. The route accepts any one of these bearer tokens:
CRON_PROCESS_EXPIRY_TOKENCRON_SECRETCRON_TOKEN
In every environment, the route requires a matching bearer token.
Example cron command (cPanel / shell):
curl -i -m 60 \
-H "Authorization: Bearer $CRON_PROCESS_EXPIRY_TOKEN" \
"https://yourdomain.com/api/cron/process-expiry" \
>> /home/<user>/cron-process-expiry.log 2>&1As a fallback, app/dashboard/(valid)/layout.tsx calls getCurrentUserWithFallback() → ensureUserExists() on every dashboard visit. This runs a lightweight on-access check that expires stale subscriptions and resets monthly free tokens without requiring the cron job to have run.
By default, uploaded files are stored on the local filesystem. Switch to S3 (or any S3-compatible provider):
FILE_STORAGE="s3"
FILE_S3_BUCKET="my-bucket-name"
AWS_REGION=""
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
FILE_CDN_DOMAIN="" # Optional: CloudFront distribution domain (recommended)
FILE_S3_ENDPOINT="" # Optional: Custom S3-compatible endpoint (Cloudflare R2, MinIO, DigitalOcean Spaces)S3-compatible providers: Set
FILE_S3_ENDPOINTto your provider's endpoint URL (e.g.https://<account>.r2.cloudflarestorage.comfor Cloudflare R2). Leave it blank for standard AWS S3.
When FILE_CDN_DOMAIN is set, the upload handler returns CDN URLs instead of raw S3 links.
Legacy aliases are still accepted by the runtime: LOGO_STORAGE, LOGO_S3_BUCKET, LOGO_S3_ENDPOINT, and LOGO_CDN_DOMAIN.
File upload scoping: The saveAdminFile helper in lib/fileStorage.js scopes uploads to sub-directories based on context (e.g. /logos/, /files/) to keep the bucket organized.
Add a CORS policy to your bucket (Permissions → CORS):
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "PUT", "POST", "HEAD"],
"AllowedOrigins": ["https://yourdomain.com"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]Front your bucket with CloudFront and set:
- Response headers policy:
CORS-With-Preflight(AWS managed) - Origin request policy:
CORS-S3Origin - Cache policy:
CachingOptimized - Allowed methods:
GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE
Add all S3 / CloudFront hostnames to next.config.mjs → images.remotePatterns.
The admin traffic dashboard can pull metrics from Google Analytics 4 or PostHog. Select the provider with TRAFFIC_ANALYTICS_PROVIDER.
External provider tracking is loaded on non-admin app routes only. The browser tracker intentionally excludes /admin pages.
| Variable | Required | Scope | Example |
|---|---|---|---|
TRAFFIC_ANALYTICS_PROVIDER |
optional | Server | google-analytics or posthog |
NEXT_PUBLIC_GA_MEASUREMENT_ID |
✅ | Client | G-XXXXXXXXXX |
GA_PROPERTY_ID |
Google only | Server | 123456789 |
GA_SERVICE_ACCOUNT_CREDENTIALS_B64 |
Google only | Server | Base64-encoded service account JSON |
GA_DATA_API_CACHE_SECONDS |
optional | Server | 30 |
POSTHOG_PROJECT_ID |
PostHog only | Server | 12345 |
POSTHOG_PERSONAL_API_KEY |
PostHog only | Server | Personal API key with query:read |
POSTHOG_APP_HOST |
optional | Server | https://us.posthog.com |
NEXT_PUBLIC_POSTHOG_KEY |
PostHog only | Client | PostHog Project token from the dashboard |
NEXT_PUBLIC_POSTHOG_HOST |
optional | Client | https://us.i.posthog.com |
Google Analytics setup:
- Create a service account in Google Cloud with
analytics.readonlyscope. - In GA4 → Admin → Property Access Management, add the service account email with at least Viewer role.
- Base64-encode your service account JSON:
base64 -i key.json.
PostHog setup:
- Set
TRAFFIC_ANALYTICS_PROVIDER=posthog. - Create a personal API key with
query:readpermission and store it inPOSTHOG_PERSONAL_API_KEY. - Set
POSTHOG_PROJECT_IDto the project backing your product analytics. - Set
NEXT_PUBLIC_POSTHOG_KEYto the browser-side PostHog Project token shown in the dashboard. - Set
POSTHOG_APP_HOSTandNEXT_PUBLIC_POSTHOG_HOSTif you are not using the default US cloud hosts.
PostHog pageview capture in this boilerplate is manual and excludes /admin routes. The admin area still uses the backend analytics adapter for /admin/traffic reporting.
Heads-up: The GA snippet loads in every environment once
NEXT_PUBLIC_GA_MEASUREMENT_IDis set. Use a dev GA4 property locally to avoid polluting production data.
Provider metrics surfaced: total visits, unique visitors, page views, avg. session duration, top referrers, top pages, countries, device mix, and events. GA4 also supports native new-user and engagement metrics. PostHog replaces those cards with supported metrics such as bounce rate, views per visit, and estimated engaged visits.
The app includes lightweight first-party visit tracking via lib/visit-tracking.ts and the VisitLog model. Middleware (POST /api/internal/track-visit) records visits for admin traffic reporting, skipping API routes, static files, admin routes, and bots. External browser analytics tracking also skips admin routes. This is the built-in self-hosted analytics path alongside Google Analytics or PostHog.
Historical note: the repository still contains deprecated Umami ops playbooks in
ops/, but they are no longer part of the supported analytics setup.
The app includes a DB-backed maintenance mode that can be toggled from the admin dashboard.
- Admin toggle:
/admin/maintenance— enable/disable maintenance mode - Behavior: When enabled, all public routes redirect to
/maintenancewith a branded "under maintenance" page - Bypass paths: Admin pages (
/admin/*), auth pages (/sign-in,/sign-up), API routes for auth/webhooks/cron/health, and/access-deniedare always accessible - Implementation:
lib/maintenance-mode.tsreads from the settings system — no env var or restart needed
The app tracks user session activity with device/browser detection and IP-based geolocation.
- User experience: Session and security controls are surfaced in the unified profile hub at
/dashboard/profileunder the "Security & Data" tab. The legacy/dashboard/accountroute redirects there. - Session tracking:
lib/session-activity.tsparses User-Agent for browser name/version and device type (desktop/mobile/tablet) - Geolocation: Uses
IPINFO_LITE_TOKENfor IP lookups when configured, falls back tocountry.is(free, no API key needed). Results are cached for 24 hours. - Session revocation: Users can revoke individual sessions (when using an auth provider that supports it)
- Activity refresh: Sessions are refreshed every 5 minutes to avoid unnecessary writes
In addition to ADMIN, the app supports a Moderator role with configurable per-section access.
- Admin config:
/admin/moderation— enable/disable which dashboard sections a moderator can access. - Sections available:
users,transactions,purchases,subscriptions,support,notifications,blog,analytics,traffic,organizations. - Moderator activity log: The moderation page at
/admin/moderationincludes the action timeline./admin/moderator-activityis kept as a legacy redirect to that page. - Lib:
lib/moderator.ts/lib/moderator-shared.ts—MODERATOR_SECTIONS, access checking helpers. - API:
/api/admin/moderator-actions/route.ts
Moderators see only the sections their config allows; they cannot change settings, manage plans, or perform billing operations.
The app includes database-backed rate limiting via the RateLimitBucket model and lib/rateLimit.ts.
| Tier | Limit | Window | Use Case |
|---|---|---|---|
API_GENERAL |
100 req | 15 min | General API endpoints |
API_SENSITIVE |
10 req | 15 min | Password changes, account deletion |
CHECKOUT |
5 req | 1 min | Checkout creation |
WEBHOOK |
1000 req | 1 min | Inbound webhooks (fail-open) |
EXPORT |
20 req | 1 min | Data exports |
AUTH |
20 req | 15 min | Login attempts |
- Composite keys — rate limits by IP + User-Agent combination
- Response headers —
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset - Automatic cleanup — expired buckets are pruned probabilistically every ~100 requests
- Fail-open option —
skipOnError: trueprevents rate limiter DB issues from blocking requests - Admin-aware —
adminRateLimithelper uses actor ID when available, falls back to IP
A production-safe logging system that replaces raw console.log throughout the app:
- Auto-redaction of sensitive keys (passwords, tokens, secrets, API keys)
- Persistent storage — WARN and ERROR logs are saved to the
SystemLogmodel (viewable at/admin/logs) - Optional Sentry fan-out — when
SENTRY_ENABLEDis set with a DSN, production logger events and React error boundaries forward to Sentry without replacing the built-in admin log view; local logger fan-out can also be enabled explicitly withSENTRY_CAPTURE_IN_DEVELOPMENT=true - Auto-pruning at 1,000 max entries
- Structured logging with timestamps and sanitized metadata
The AdminActionLog model records all admin/moderator actions:
- Fields: actor, target user, action type, details, timestamp
- Viewable at:
/admin/moderation(the legacy/admin/moderator-activityroute redirects there)
next.config.mjs sets comprehensive security headers on all routes:
X-Frame-Options: DENY— prevents clickjackingX-Content-Type-Options: nosniff— prevents MIME sniffingStrict-Transport-Security— enforces HTTPS (1 year, includeSubDomains)Referrer-Policy: origin-when-cross-originX-XSS-Protection: 1; mode=blockPermissions-Policy— disables camera, microphone, geolocation, payment- API routes:
Cache-Control: no-store, max-age=0 Content-Security-Policy— source allowlist for scripts, frames, embeds, and outbound connections
lib/secure-errors.ts provides structured error classes (AppError, ValidationError, AuthenticationError, AuthorizationError, etc.) that:
- Expose safe, operational error messages to clients
- Hide internal error details in production
- Include
X-Request-IDheaders for support/debugging
ENCRYPTION_SECRET— encrypts sensitive DB fields at rest, including reusable payment authorization codes- Webhook signature verification with rotation support (comma-separated secrets)
- Price validation on webhook events
- Bcrypt password hashing for NextAuth credentials users (12 salt rounds)
- Single-use password reset flow — reset tokens are generated with cryptographic randomness, stored hashed, expire after 1 hour, and active sessions are revoked after reset
- Password policy enforcement (
lib/password-policy.ts) — minimum 8 chars, uppercase, lowercase, and number by default - Token version tracking — incremented on password change to invalidate existing sessions
- Secure credentials cookies —
HttpOnly,SameSite=Lax, andSecureon HTTPS for the built-in credentials sign-in route - Generic auth recovery responses on forgot-password and resend-verification routes to reduce email enumeration risk
- Account suspension controls — admins can temporarily or permanently suspend users, which blocks new sign-ins and invalidates provider-backed access checks consistently across Clerk, NextAuth, and Better Auth
The app supports system-preference and manual dark mode:
- Detection: Reads
localStorage.themePreference, falls back toprefers-color-schememedia query - Flash prevention: An inline
<script>runs before React hydration to set the theme class on<html>, preventing light → dark flash - Toggle:
ThemeTogglecomponent available in the header - CSS classes:
html.lightandhtml.dark— all theme color variables are generated for both modes - Persistence: User preference saved to
localStorage.themePreference
The app includes comprehensive testing infrastructure:
npm test # Run all unit tests
npm test -- --watch # Watch mode140+ test files covering 500+ individual tests:
- Payment provider flows (Stripe, Paystack, Paddle, Razorpay)
- Webhook handling and event normalization
- Subscription lifecycle (checkout, proration, cancellation, resurrection)
- Team/organization operations and provisioning
- Token spending and organization scoping
- Auth flows, route guards, and session management
- Admin operations, sorting, and filtering
- Coupon redemption and plan resolution
- Support ticket categories and cursor pagination
The admin dashboard (/admin) is organized into logical groups:
| Group | Sections |
|---|---|
| Overview | Dashboard home with quick stats |
| Users & Access | Users, Organizations, Moderation |
| Finances | Transactions, One-Time Sales, Subscriptions, Coupons |
| Platform | Theme, Pages, Blog, Plans, Email Templates, Settings |
| Support & Analytics | Support Tickets, Notifications, Analytics (GA4), Traffic |
| Developer | System, System Logs, Maintenance |
- System (
/admin/system) — live environment readiness, runtime/build details, storage, webhook, bearer-token, and maintenance status snapshot for operators - Maintenance Tools (
/admin/maintenance) — cleanup, repair utilities, and maintenance mode toggle - System Logs (
/admin/logs) — persisted WARN/ERROR logs with filtering - One-Time Plans (
/admin/one-time-plans) — manage non-recurring offers, including fixed-duration and lifetime access plans - Account controls (
/admin/users,/admin/organizations) — suspend users with temporary/permanent messaging, inspect suspension reason/date in admin UI, and suspend or restore organizations without deleting the local workspace record
The user dashboard (/dashboard) provides users with a full self-service experience:
| Page | Path | Description |
|---|---|---|
| Home | /dashboard |
Overview and the shipped SaaSyApp demo workspace with current plan, token balance, and quick stats |
| Onboarding | /dashboard/onboarding |
Guided setup for new users |
| Plan | /dashboard/plan |
Current plan details and upgrade options |
| Billing | /dashboard/billing |
Payment management, manage subscription |
| Transactions | /dashboard/transactions |
Payment history |
| Team | /dashboard/team |
Team management (invites, members, settings) |
| Profile | /dashboard/profile |
Unified account hub for profile details, preferences, session security, export, and account deletion |
| Account | /dashboard/account |
Legacy redirect to /dashboard/profile |
| Settings | /dashboard/settings |
Legacy entry point that redirects into the unified profile/settings experience |
| Notifications | /dashboard/notifications |
In-app notification center |
| Support | /dashboard/support |
Support ticket creation and history |
| Coupons | /dashboard/coupons |
Redeemed coupons and pending redemptions |
| Legacy redirects | /dashboard/editor, /dashboard/sassyapp |
Redirect to /dashboard |
The main dashboard page is the place to replace the demo SaaSyApp experience with your own product logic.
Users can also switch workspace context from the dashboard sidebar footer. That switcher is important because a personal workspace and a team workspace can expose different billing state, token pools, and feature access.
Complete these steps after local development is finished and before you point real traffic at the app:
- Provision a hosted PostgreSQL database and update
DATABASE_URL. - Run production migrations with
npx prisma migrate deployagainst that production database. - Configure all production env vars for your chosen auth provider, payment provider, email provider, and secrets.
- If admins will upload logos or other managed files in production, switch from local filesystem storage to S3-compatible storage.
- Configure your webhook endpoints and verify signatures before accepting live traffic.
For Coolify and similar self-hosted platforms, set the production environment variables in the platform UI before the first deploy attempt. This app reads the database during npm run build for static generation, so a missing or broken DATABASE_URL can fail the build before the app ever starts.
If your team previously developed against SQLite and you are now moving to PostgreSQL, do not reuse that SQLite migration chain or local dev.db file in production. Start from a fresh PostgreSQL database and follow docs/prisma-provider-migrations.md for the supported recovery path.
SaaSyBase is stable enough now that most releases should be routine maintenance: improvements, bug fixes, security work, and polish. In plain language, most updates should not require deep code surgery.
Here, “upstream” just means the newer official SaaSyBase version you are updating from.
For operators, the short safe workflow is:
- Read the release summary first to see whether the update is routine or whether it specifically mentions infrastructure changes.
- Back up the production database and confirm the effective deployed env configuration.
- Merge the update in Git first, then validate it in staging with production-like providers and storage.
- Run production with the normal ordered flow:
npm run prisma:deploy, then build, then start or promote the release. - After deploy, verify
/api/health, sign-in, billing, webhook delivery, cron auth, uploads, and logs before you call the release complete.
Do not assume every release needs Prisma or database work. Only switch into migration-specific steps when the release actually includes schema changes. Also do not rerun npx prisma db seed as part of routine upgrades. Seed data is for first-time bootstrap or deliberate demo/local setup, not normal live releases.
The full operator guide is in app/docs/updates-and-upgrades/page.tsx for the docs page source and at /docs/updates-and-upgrades in the running app.
# Strong recommendation: for staging and production, prefer platform-native encrypted env vars.
# If your team wants one centralized secret store across multiple platforms,
# SaaSyBase can optionally bootstrap missing values from Infisical or Doppler.
# Optional built-in secrets-provider bootstrap:
# SECRETS_PROVIDER="infisical" # or "doppler"
# SECRETS_PROVIDER_COMMAND="" # optional full command override
# SECRETS_PROVIDER_SECRETS="DATABASE_URL,ENCRYPTION_SECRET" # optional narrowing allowlist; omit to allow any missing provider keys
# INFISICAL_PROJECT_ID="your-project-id"
# INFISICAL_ENVIRONMENT="production"
# DOPPLER_PROJECT="saasybase"
# DOPPLER_CONFIG="prd"
# Core
DATABASE_URL="postgresql://..."
NEXT_PUBLIC_APP_URL="https://yourdomain.com"
NEXT_PUBLIC_APP_DOMAIN="yourdomain.com"
NEXT_PUBLIC_SITE_NAME="Your App"
# Auth (pick one)
AUTH_PROVIDER="betterauth" # or "nextauth" or "clerk"
# Payment (pick one)
PAYMENT_PROVIDER="stripe"
# Currency (optional, defaults to provider default)
PAYMENTS_CURRENCY="USD"
# Security
ENCRYPTION_SECRET="" # Encrypt sensitive DB fields
INTERNAL_API_TOKEN="" # Server-to-server endpoints (/api/internal/*)
HEALTHCHECK_TOKEN="" # Auth for /api/health detailed output
CRON_PROCESS_EXPIRY_TOKEN="" # Auth for /api/cron/process-expiry
CRON_SECRET="" # Optional: Vercel Cron secret (Authorization header)
# Email
EMAIL_PROVIDER="nodemailer" # or "resend"
SMTP_HOST=""
SMTP_PORT=""
SMTP_USER=""
SMTP_PASS=""
RESEND_API_KEY=""
EMAIL_FROM=""
SUPPORT_EMAIL=""
SEND_ADMIN_BILLING_EMAILS="true"Notes:
- If
EMAIL_PROVIDERis unset, the app defaults tonodemailer. - Use
EMAIL_PROVIDER="nodemailer"for SMTP delivery. In local development, the default SMTP values can point at MailHog. - Use
EMAIL_PROVIDER="resend"withRESEND_API_KEYset. TheSMTP_*variables are ignored in that mode.
Coolify should not rely on plain npm run build alone for first deploys or schema changes. In this repo, build runs Prisma client generation plus Next.js build, but it does not apply migrations.
Recommended Coolify setup:
# Build command
npm run deploy:build
# Start command
npm run startnpm run deploy:build runs:
npm run prisma:deploy && npm run buildSet all required env vars in Coolify before the first deployment attempt. Also use a DATABASE_URL that works in the build container, not only at runtime. If the URL depends on a local CA file path such as sslrootcert=/etc/ssl/certs/coolify-ca.crt, the build can fail during Prisma access unless that certificate file is present inside the builder image too.
If you are new to this, use this rule:
- local development: keep using
.env.local - staging and production: use your platform's encrypted env vars by default
- centralized secret store across multiple platforms: opt into Infisical or Doppler bootstrap
SaaSyBase stays env-driven. The built-in wrappers load .env files first, then optionally ask Infisical or Doppler for missing server-side secrets before build, start, and Prisma commands run.
Simple setup flow:
- Decide whether you actually need bootstrap. If Vercel, Coolify, Railway, Render, Docker, or your VPS already inject env vars cleanly, you may not need it.
- Set
SECRETS_PROVIDER=infisicalorSECRETS_PROVIDER=doppleronly when you want centralized secret loading. - Keep provider authentication in the provider's own CLI flow or machine identity setup instead of storing cloud-service JSON keys in env.
- Optionally set
SECRETS_PROVIDER_COMMANDwhen you want the app to run a custom export command instead of the built-in default. - Run
npm run secrets:smokebefore the first real deploy. - Deploy normally in order:
npm run prisma:deploy, thennpm run build, thennpm run start.
Built-in default commands:
- Infisical:
infisical export --format json - Doppler:
doppler secrets download --no-file --format json
Important behavior:
- The app does not open an interactive provider login flow for the user.
- It expects the selected provider CLI to already be installed and authenticated in the current shell, CI job, or server runtime.
- For local Infisical usage, the minimum mental model is: install the CLI, run
infisical login, setINFISICAL_PROJECT_IDandINFISICAL_ENVIRONMENT, then runnpm run secrets:smoke. - For local Doppler usage, the minimum mental model is: install the CLI, run
doppler login, setDOPPLER_PROJECTandDOPPLER_CONFIG, then runnpm run secrets:smoke. - If you want a preflight check before boot, run
npm run secrets:doctor.
Official docs:
- Infisical CLI overview: https://infisical.com/docs/cli/overview
- Infisical export command: https://infisical.com/docs/cli/commands/export
- Doppler CLI docs: https://docs.doppler.com/docs/cli
- Doppler secrets access docs: https://docs.doppler.com/docs/accessing-secrets
Optional provider hints:
INFISICAL_PROJECT_IDINFISICAL_ENVIRONMENTDOPPLER_PROJECTDOPPLER_CONFIGSECRETS_PROVIDER_SECRETSas a comma-separated allowlist when you want to narrow bootstrap to a specific subset of provider keys
The app only fills missing values. If a value is already present in platform envs or .env.local, it wins.
Common errors and what they usually mean:
| Error | Usual cause | Fix |
|---|---|---|
provider command not found |
The Infisical or Doppler CLI is not installed or not on PATH |
Install the CLI or set SECRETS_PROVIDER_COMMAND explicitly |
| Provider auth error | The CLI exists, but it is not authenticated | Use the provider's machine identity, service token, or login flow |
| Smoke test says a required value is missing | The export command ran, but the expected env var was not present in the provider output | Verify the env-var names in the provider project/config or set them directly in platform envs |
| Unexpected bootstrap behavior | Your provider setup needs different flags than the built-in defaults | Set SECRETS_PROVIDER_COMMAND to your exact provider export command |
If Clerk keys are loaded but the browser still shows Failed to load Clerk JS, check these in order:
- Confirm
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYresolves to the expected Clerk instance. - Open the browser devtools Network tab and check whether the Clerk JS URL returns a real response or is blocked.
- Check your browser console for a Content Security Policy error. SaaSyBase keeps CSP disabled by default because third-party auth and payment providers often need extra origins. If you enable CSP yourself, you need to maintain the allowlist for Clerk and any CAPTCHA provider it pulls in.
- If you changed env vars or CSP recently, fully restart the app and hard-refresh the browser.
- On privacy-focused browsers or extensions, temporarily disable blockers for the site to rule out a client-side block.
The Clerk JS URL usually looks like:
https://<your-clerk-instance>.clerk.accounts.dev/npm/@clerk/clerk-js@6/dist/clerk.browser.js
If that exact URL is blocked by CSP, the app is loading the right key but the browser is not allowed to fetch Clerk's hosted script.
Base64 examples:
# macOS / Linux
base64 -i service-account.json | tr -d '\n'
# alternative that works on many Linux shells
base64 -w 0 service-account.jsonSupported bootstrap env vars:
SECRETS_PROVIDER=infisicalorSECRETS_PROVIDER=dopplerenables the integrationSECRETS_PROVIDER_COMMANDoverrides the provider CLI command completelySECRETS_PROVIDER_SECRETSprovides an optional comma-separated allowlist of env var names to fetch; when omitted, any missing env var returned by the provider is eligible to loadINFISICAL_PROJECT_IDandINFISICAL_ENVIRONMENTshape the default Infisical export commandDOPPLER_PROJECTandDOPPLER_CONFIGshape the default Doppler export command
Existing env values are not overwritten. That keeps env-file and platform-env fallback intact and makes staged migrations possible.
Smoke test the staging configuration without starting the app:
SECRETS_PROVIDER=infisical \
INFISICAL_ENVIRONMENT=staging \
npm run secrets:smokeThis remains optional. If you prefer to keep managing secrets directly in platform env vars or env files, SaaSyBase will still work because the app reads from process.env. The tradeoff is operational consistency: centralized secret stores make rotation and multi-environment hygiene easier, but they should stay opt-in rather than assumed.
If you want the beginner-friendly walkthrough, use the app docs page at /docs/secrets.
Recommended split:
- Platform envs or optional Infisical/Doppler bootstrap: all server-side secrets such as
DATABASE_URL,ENCRYPTION_SECRET, provider secret keys, webhook secrets, and internal bearer tokens - Regular env config: non-secret deploy config such as
AUTH_PROVIDER,PAYMENT_PROVIDER, URLs, branding, and public keys .env.local: local-only development values and test helpers
For the staged rollout and rotation sequence, see docs/secret-inventory-rotation-plan-2026-04-21.md. For simpler deployment recipes and copy-paste examples, see docs/secrets-provider-deploy-examples.md.
GET /api/health
Authorization: Bearer <HEALTHCHECK_TOKEN>
Returns database connectivity, environment validation, active auth/payment provider diagnostics, and runtime health checks. Without authorization, it returns a minimal public response.
Auth token resolution:
- Requires
HEALTHCHECK_TOKENfor detailed output in production.
SaaSyBase does not need much Vercel-specific config, but there are a few production realities you should handle explicitly:
- Import the repo into Vercel and let Next.js auto-detect the framework.
- Set production env vars in the Vercel project settings.
- Use PostgreSQL, not SQLite.
- Run
npx prisma migrate deployagainst the production database before the first live release and on future schema changes. Vercel does not apply Prisma migrations for you automatically. - If you need admin-managed uploads (logos, blog assets, similar files), use
FILE_STORAGE="s3"plus S3-compatible credentials. On Vercel, persistent app-managed uploads should be treated as required S3 storage because the local filesystem is not a durable production store. - Set
CRON_SECRETin Vercel if you want the built-in Vercel cron job to call/api/cron/process-expiry. The shippedvercel.jsonschedules that route once per day at03:00 UTC.
Notes:
- The default
vercel.jsoncron schedule is intentionally conservative so it works on Vercel Hobby plans too. If you need faster cleanup and your plan supports it, increase the frequency. - If you want manual or external cron callers in addition to Vercel Cron, you can also keep
CRON_PROCESS_EXPIRY_TOKENset. - If you want Vercel to fetch secrets through the built-in bootstrap at build/runtime, use the Infisical or Doppler patterns in docs/secrets-provider-deploy-examples.md.
- Go to Clerk Dashboard → Webhooks → Add endpoint.
- URL:
https://yourproductiondomain.com/api/webhooks/clerk - Enable events:
user.created,user.updated,organization.*,organizationMembership.*,organizationInvitation.* - Copy the signing secret into
CLERK_WEBHOOK_SECRET.
- Go to Stripe Dashboard → Developers → Webhooks → Add endpoint.
- URL:
https://yourproductiondomain.com/api/webhooks/payments(or/api/webhooks/stripeor/api/stripe/webhook) - Enable the recommended events listed in the Stripe section above.
- Copy the signing secret into
STRIPE_WEBHOOK_SECRET.
Multiple comma-separated secrets are supported for rotation:
STRIPE_WEBHOOK_SECRET="whsec_primary,whsec_rotating"DATABASE_URLpoints at PostgreSQL.NEXT_PUBLIC_APP_URLmatches the production domain exactly.AUTH_PROVIDERandPAYMENT_PROVIDERare set deliberately.CRON_SECRETis configured if using the bundled Vercel cron.FILE_STORAGE="s3"is configured if you need durable uploads.- Clerk and payment webhooks point at the production domain.
- New tickets/user replies → email to
SUPPORT_EMAIL. - Admin replies → email to ticket owner (respects user setting
EMAIL_NOTIFICATIONS). - If
EMAIL_PROVIDER="nodemailer", configure SMTP above; without it, Nodemailer falls back to an in-memory stream transport (emails won't deliver in production). - If
EMAIL_PROVIDER="resend", setRESEND_API_KEY; SMTP settings are not used.
Coolify can deploy SaaSyBase as a standard Node/Next.js app without a custom Dockerfile.
Pin the application runtime to a supported Node.js version before the first deploy. This project supports Node.js ^20.19.0, ^22.12.0, or >=24.0.0. Do not rely on older platform defaults such as Node 18.
Recommended setup:
- Connect the repository as a Node or Nixpacks-style application.
- Set the build command to
npm run build. - Set the start command to
npm run start. - Configure
npm run prisma:deployas a pre-deploy step or deployment hook so every release applies migrations before the app starts. A one-time manual run is only a fallback, not the steady-state production workflow. - Use PostgreSQL for production data.
- Use S3-compatible storage if you need durable uploaded assets across container restarts or reschedules.
- Configure a scheduled HTTP job for
/api/cron/process-expirywithAuthorization: Bearer <CRON_PROCESS_EXPIRY_TOKEN>. The route also acceptsCRON_SECRETandCRON_TOKEN, but using one canonical name avoids confusion. - If you are using the built-in secrets bootstrap, authenticate the Infisical or Doppler CLI using that provider's recommended machine identity or service token flow. Examples are in docs/secrets-provider-deploy-examples.md.
For bare-metal / VPS hosts (AlmaLinux, RHEL, Ubuntu):
Install and pin a supported Node.js runtime first. Match the version policy in package.json#engines: Node.js ^20.19.0, ^22.12.0, or >=24.0.0.
Option 1 — systemd EnvironmentFile
# Create env file
TOKEN=$(openssl rand -hex 32)
sudo install -o appuser -g appuser -m 600 /dev/null /etc/saasybase/app.env
sudo tee /etc/saasybase/app.env > /dev/null << EOF
HEALTHCHECK_TOKEN=$TOKEN
DATABASE_URL=postgresql://...
# ...other vars
EOFReference it in your service unit:
[Service]
EnvironmentFile=/etc/saasybase/app.env
ExecStart=/usr/bin/npm run start
WorkingDirectory=/var/www/saasybaseOption 2 — dotenv alongside the app
set -a; source .env.production; set +a
npm run startTypical production sequence:
npm install
npm run secrets:smoke
npx prisma migrate deploy
npm run build
npm run startRun the app under systemd, pm2, or another process manager. systemd is the simplest default on most Linux VPS hosts.
Example site block:
server {
server_name yourdomain.com www.yourdomain.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection upgrade;
}
}Pair this with TLS via Let's Encrypt or your existing certificate automation.
Example VirtualHost:
<VirtualHost *:80>
ServerName yourdomain.com
ServerAlias www.yourdomain.com
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:3000/
ProxyPassReverse / http://127.0.0.1:3000/
RequestHeader set X-Forwarded-Proto "http"
</VirtualHost>Enable the required proxy modules (proxy, proxy_http, headers) and terminate TLS in your HTTPS virtual host.
curl -i -m 60 \
-H "Authorization: Bearer $CRON_PROCESS_EXPIRY_TOKEN" \
"https://yourdomain.com/api/cron/process-expiry"A complete list of supported env vars is in .env.example. Key groups:
| Group | Key prefix | Notes |
|---|---|---|
| Database | DATABASE_URL |
Points Prisma at the target database. The committed provider/migration lane is PostgreSQL. |
| App | NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_SITE_NAME, NEXT_PUBLIC_APP_DOMAIN |
Public-facing URL and branding |
| Branding | NEXT_PUBLIC_SITE_LOGO, NEXT_PUBLIC_SITE_LOGO_LIGHT/DARK, NEXT_PUBLIC_SITE_LOGO_HEIGHT |
Site logo configuration |
| Auth | AUTH_PROVIDER, CLERK_*, BETTER_AUTH_URL, NEXT_PUBLIC_BETTER_AUTH_URL, BETTER_AUTH_SECRET, AUTH_SECRET, NEXTAUTH_SECRET |
Choose Clerk, Better Auth, or NextAuth |
| Auth OAuth | GITHUB_CLIENT_ID/SECRET, GOOGLE_CLIENT_ID/SECRET |
NextAuth OAuth providers |
| Payment | PAYMENT_PROVIDER, STRIPE_*, PAYSTACK_*, PADDLE_*, RAZORPAY_* |
Choose provider |
| Payment catalog | PAYMENT_AUTO_CREATE, provider credentials, DB PlanPrice records |
Seeded plans no longer require manual PAYMENT_PRICE_* or SUBSCRIPTION_PRICE_* env vars |
| Payment config | PAYMENT_AUTO_CREATE, PAYMENTS_CURRENCY |
Catalog sync and currency |
| Currency settings | DEFAULT_CURRENCY |
DB-backed admin setting used by payment currency resolution |
| Currency | PADDLE_CURRENCY, PAYSTACK_CURRENCY, RAZORPAY_CURRENCY |
Per-provider currency overrides |
EMAIL_PROVIDER, SMTP_*, RESEND_API_KEY, EMAIL_FROM, SUPPORT_EMAIL |
Switch between SMTP/Nodemailer and Resend | |
| Geolocation | IPINFO_LITE_TOKEN |
Optional; activity geolocation falls back to country.is when unset |
| Storage | FILE_STORAGE, FILE_S3_BUCKET, FILE_S3_ENDPOINT, AWS_*, FILE_CDN_DOMAIN |
Local fs, S3, or S3-compatible (R2, MinIO). Legacy LOGO_* aliases still work |
| Analytics | NEXT_PUBLIC_GA_MEASUREMENT_ID, GA_* |
Google Analytics 4 |
| Security | ENCRYPTION_SECRET, INTERNAL_API_TOKEN, HEALTHCHECK_TOKEN, CRON_PROCESS_EXPIRY_TOKEN, CRON_SECRET |
Server-side secrets |
| Demo | DEMO_READ_ONLY_MODE |
Read-only demo mode |
| Paddle sandbox | PADDLE_ENV, NEXT_PUBLIC_PADDLE_ENV |
Sandbox/production toggle |
| Seeding | SEED_ADMIN_EMAIL, SEED_ADMIN_PASSWORD |
Non-interactive admin creation |
| Dev helpers | ALLOW_UNSIGNED_CLERK_WEBHOOKS, ALLOW_SYNC_IN_PROD |
Break-glass local/dev helpers only |
If you want to share a safe, explorable demo (including admin UI) without allowing data changes, enable:
DEMO_READ_ONLY_MODE="true"When enabled:
POST,PUT,PATCH, andDELETErequests to/api/*are blocked with403.- The write-method exemptions are exactly
/api/auth/*,/api/webhooks/*, and/api/stripe/webhook, so sign-in and provider callbacks still work. - A read-only modal appears after entering admin or dashboard, and blocked actions trigger an informational toast.
Recommended setup:
- Run this mode on a dedicated demo deployment/environment.
- Use seeded demo accounts only (for example
admin@saasybase.com/password). - Keep production with
DEMO_READ_ONLY_MODE="false".
This codebase is production-oriented but you should review your environment configuration, authentication posture, and security setup before going live. In particular:
- Rotate all secrets before deploying.
- Enable signature verification for all webhooks.
- Use a hosted PostgreSQL instance in production (not SQLite).
- Treat
ALLOW_UNSIGNED_CLERK_WEBHOOKSandALLOW_SYNC_IN_PRODas break-glass only.ALLOW_UNSIGNED_CLERK_WEBHOOKSis for explicit localhost debugging only and must never be enabled for staging or preview environments.
