Tech Stack
| Category | Technology |
|---|---|
| Framework | Next.js 16 (App Router), React 19, TypeScript |
| Database | Shared Prisma + PostgreSQL on Supabase or Neon, with common Account-scoped SQL/RLS and explicit provider overlays. |
| Auth | Supabase Auth or Better Auth, selected once per deployment — provider-neutral Magic Link + OTP flows, canonical application-identity finalization, cross-tab session observation, and config-driven OAuth. |
| Payments | Stripe, Lemon Squeezy, or Paddle selected once per installation; provider-normalized subscriptions and one-time purchases. |
| AI | LangChain + Multi-LLM (OpenAI, Anthropic, Google) |
Provider-agnostic abstraction — Brevo or Mailjet (transactional + newsletter), pluggable via EMAIL_PROVIDER |
|
| Styling | Tailwind CSS 4, shadcn/ui (New York style), hue-parameterised oklch tokens — rotate --brand-h in app/globals.css to rebrand the whole product |
| Fonts | DM Sans (body) + JetBrains Mono (numerals, code, logs), self-hosted via next/font/google |
| Animation | Motion (motion) mounted through LazyMotion on the marketing surface only; dashboards use CSS transitions on the duration/easing tokens |
| i18n | URL-based routing (/fr-FR/..., /en-US/..., /en-CA/..., /fr-CH/...) |
| Security | CSRF, rate limiting (Upstash Redis), CSP headers, recent-auth gates for destructive admin actions |
| Bot Protection | Cloudflare Turnstile (invisible CAPTCHA) |
| Analytics | Google Tag Manager, Google Ads, Meta Pixel, X Pixel (all consent-gated) |
| Live Chat | Crisp (consent-gated user identification) |
| Caching | Upstash Redis (rate limiting, sessions) |
| Background Jobs | Native Supabase/Neon pg_cron or external scheduler + application workers |
Third-Party Integrations
The boilerplate includes pre-configured integrations for common SaaS needs:
Payment providers
Billing has provider-neutral contracts and adapter integration for Stripe, Lemon Squeezy, and Paddle. The initialization script selects exactly one provider in server-only PAYMENTS_PROVIDER; customers never choose it and an existing installation cannot switch in place. All three providers implement member checkout and customer portal locally. Paddle uses an approved local Paddle.js page and keeps a durably prelinked member transaction payable without treating the browser-presentation TTL as a financial expiry; its anonymous guest surface stays closed because it lacks that Account-owned prelink. Shared domain effects own payments, credits, entitlements, holds, and reconciliation. Plans and provider-, currency-, interval-, and mode-scoped catalogue bindings are defined in config/pricing.ts, not the database. Stripe acts as payment processor for the application seller; Lemon Squeezy and Paddle are Merchant of Record.
Database, Auth and Storage providers
DATABASE_PROVIDER selects Supabase PostgreSQL or Neon for the shared Prisma business runtime; AUTH_PROVIDER independently selects Supabase Auth or Better Auth; STORAGE_PROVIDER independently selects disabled, Supabase Storage, Neon Object Storage, or AWS S3. Product domains resolve a provider principal into private.app_users and then authorize only with the resulting application actor and Account memberships. Object access stays behind server-only adapters: private documents use signed URLs and public CMS media uses a configured bucket/CDN base URL. Provider-native clients never become a business-database portability escape hatch. Background work runs through the durable shared job registry; a Supabase Edge Function may invoke it but does not own business persistence.
Adding your own account-scoped table
Every domain table carries account_id (never a bare user_id) and is filtered by membership. Replicate this pattern for any new core/app table, and update its owning source under database/ in the same commit. Run pnpm run db:schema:check to validate assembly. The source and manifest are the sole DDL authority; init and guarded local resets install complete SQL batches in memory. Optional exports are only for manual installation and are never edited or committed:
alter table my_table enable row level security;
create policy "members_read" on my_table for select
using (exists (
select 1 from memberships m
where m.account_id = my_table.account_id
and m.user_id = private.current_actor_id()
));
-- INSERT/UPDATE/DELETE must be at least as strict as SELECT
create policy "members_write" on my_table for all
using (exists (
select 1 from memberships m
where m.account_id = my_table.account_id
and m.user_id = private.current_actor_id()
));If a policy needs to query memberships recursively, use a SECURITY DEFINER helper (e.g. user_belongs_to_account()) instead of an inline sub-select to avoid RLS recursion.
AI / Multi-LLM
AI is orchestrated via LangChain behind one server-only LlmClient. AI_LLM_TRANSPORT selects direct OpenAI/Anthropic/Google APIs or OpenRouter for chat and embeddings together; gateway requests enforce ZDR/data-collection-deny routing. A dynamic registry provides chat, code-assistant, translator, writer, RAG, and custom agents without exposing provider SDKs to agent code. Streaming SSE, abort/time ceilings, provider-reported token accounting, bounded embeddings, prompt caching, and model-provider/transport audit persistence are normalized at the boundary.
Cloudflare Turnstile
Turnstile provides invisible bot protection without annoying CAPTCHAs. It's used on contact forms, login, and other sensitive endpoints.
Google Tag Manager, Google Ads, Meta Pixel & X Pixel
Analytics are consent-aware and privacy-by-default. GTM, gtag.js / Google Ads, Meta Pixel, X (Twitter) Pixel, and purchase/signup conversion events only load or fire after the user grants the matching analytics or marketing consent. Consent Mode v2 still starts deny-by-default, but third-party scripts are not mounted before consent. To enable Google Tag Manager, set NEXT_PUBLIC_GTM_ID. For Google Ads conversion tracking, set NEXT_PUBLIC_GOOGLE_ADS_ID and NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL. Optionally set NEXT_PUBLIC_GA_MEASUREMENT_ID for GA4 via gtag.js (skip if GA4 is already configured in your GTM container to avoid duplicate events). For Meta Pixel, set NEXT_PUBLIC_META_PIXEL_ID. For the X Pixel, set NEXT_PUBLIC_X_PIXEL_ID plus the optional per-action conversion event IDs NEXT_PUBLIC_X_PIXEL_EVENT_PURCHASE / NEXT_PUBLIC_X_PIXEL_EVENT_SIGNUP. A unified trackConversion() method fires purchase events only when marketing consent is granted.
Crisp Live Chat
Crisp provides live chat with automatic user identification for logged-in users. Set the NEXT_PUBLIC_CRISP_WEBSITE_ID variable to enable it. The script and user-identification payload are gated by consent, so Crisp is not loaded before the user grants the configured support/marketing consent category.
Email (Brevo / Mailjet)
Transactional emails (magic links, workspace invitations, contact-form confirmations, license expiration warnings, organization deletion notices) and newsletter management go through a provider-agnostic abstraction in lib/email/. Switching providers is a config-only change — set EMAIL_PROVIDER and supply the matching credentials.
Supported providers:
brevo— Brevo (formerly Sendinblue). Single API key (BREVO_API_KEY) +BREVO_NEWSLETTER_LIST_ID.mailjet— Mailjet Send API v3.1 + Contacts. Two keys (MAILJET_API_KEY_PUBLIC+MAILJET_API_KEY_PRIVATE) + optionalMAILJET_NEWSLETTER_LIST_ID.noop— logs and reports success without delivering. Useful for dev / CI.
The shared sender identity (EMAIL_FROM_NAME, EMAIL_FROM_ADDRESS) and inbox addresses (CONTACT_EMAIL, SUPPORT_EMAIL) apply to every provider. Every transactional template is localized for the configured locale catalogue and composes the mobile-first design-system shell in lib/email/layout.ts: fluid from 320px to a 600px cap, dark-mode aware, protected from client text amplification, and backed by an Outlook-compatible CTA. Builders live in lib/email/templates.ts; lib/email/translations.ts holds the strings. Failed transactional sends are queued in the pending_emails table and retried with exponential backoff by the process-pending-emails job.
Adding a new provider is a 3-step change: implement the EmailProvider interface under lib/email/providers/<name>.ts, add a credentials slice in config/email.ts, and add a branch in lib/email/provider.ts. No call sites need to change.
Upstash Redis
Upstash Redis provides distributed rate limiting across all API endpoints and shared LLM circuit-breaker state across workers. Set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN to enable it. Both are required in production — the app refuses to boot without them. Process-local state is per-instance, so behind a load balancer it does not merely fail to share rate limits, it leaves every endpoint effectively unlimited and lets LLM circuit decisions disagree. Local state remains the development default, and a genuinely single-instance production deployment can opt in with ALLOW_INMEMORY_RATE_LIMIT=true.
Serwist (PWA)
Serwist powers the Progressive Web App layer with service worker generation, offline support, and push notifications. It provides automatic precaching of static assets, stale-while-revalidate for resources, network-first for API calls, and an offline fallback page. Push notifications use the Web Push API with VAPID authentication, per-device subscriptions stored in the database, and user notification preferences. Configurator mode keeps Next.js on Turbopack, then @serwist/cli bundles the worker from serwist.config.mjs; SerwistProvider handles browser registration.
Account-Centric Model
Everything is attached to an Account, never directly to a User. This enables multi-tenant architecture with clear separation of resources.