Next.js + Supabase + Polar.sh SaaS Starter
Production-tested architecture for a credit-based, multi-tenant SaaS. Extracted
from a live product; every pattern here has survived real payments and real
users. Use it to scaffold a new project or audit an existing one.
Core principles (non-negotiable)
- The server owns all credit math. Balances live in Postgres and are
mutated ONLY by two
SECURITY DEFINER functions — grant_credits and
spend_credits — callable only by the service role. Client code (and even
normal server code) can never UPDATE profiles SET credits = ....
- Append-only ledger. Every credit movement writes a
credit_ledger row
with delta, reason, balance_after, and metadata. Nothing is ever
updated or deleted in the ledger. This is your audit trail and your
debugging lifeline.
- Credits are granted only from verified webhooks (or authenticated
reconciliation against the provider API) — never from a browser
success_url redirect, which can be closed, refreshed, or forged.
- Idempotency via unique constraint. The webhook inserts a
purchases
row keyed by the provider's transaction id (UNIQUE) before granting
credits. A redelivered webhook conflicts (23505) and no-ops.
- RLS = read-your-own-rows only. Users may
SELECT their own profile,
ledger, and purchases. All writes are revoked from anon/authenticated
and flow through the service role or the definer functions.
- The product catalog is server-authoritative. The webhook maps a paid
product_id back to a credit amount from a server-side catalog. It never
trusts amounts or product info from client metadata.
Scaffolding workflow
Work through these steps in order. Deep detail for each lives in references/.
1. Database schema + credit functions
Apply the migrations from assets/starter-template/supabase/migrations/:
0001_profiles_credits.sql — profiles, credit_ledger, grant_credits,
spend_credits, signup trigger, RLS.
0002_purchases.sql — purchases table with the UNIQUE idempotency key.
0003_monthly_topup.sql — optional capped monthly free-credit refill
(set-based, single transaction, driven by a cron).
Read references/credit-ledger.md for the full
rationale: row locking, why balance_after is stored, error codes, the
welcome-grant race, and how to adapt amounts/reasons to a new product.
2. Supabase clients (three of them, strictly separated)
From assets/starter-template/src/lib/supabase/:
client.ts — browser client (anon key, RLS enforced).
server.ts — cookie-bound server client for Server Components/Actions
(anon key, RLS enforced) plus createServiceClient() (service-role key,
bypasses RLS, server-only, never imported into client code).
middleware.ts — session refresh on every request.
3. Auth: Google-only OAuth via Supabase
Google-only sign-in avoids password handling entirely and works everywhere.
The critical piece is idempotent profile provisioning: a DB trigger on
auth.users INSERT plus an ensure_profile() RPC called from the OAuth
callback, so pre-existing accounts and race-y parallel logins all end up with
exactly one profile and exactly one welcome grant.
See references/auth-flow.md for the callback route,
the provisioning function, and the welcome-grant guard.
4. Polar.sh: checkout, webhook, reconciliation
Polar.sh acts as Merchant of Record — it is the seller of record and
handles global tax, which is why this works from countries where Stripe and
Paddle won't onboard you. Setup order:
- Create products (credit packs) in the Polar dashboard — sandbox first.
- Server-side catalog maps env-provided product ids → credit amounts.
- Checkout route (authenticated) creates a Polar checkout with
metadata.user_id taken from the session, never the request body.
- Webhook route uses
Webhooks() from @polar-sh/nextjs (verifies the
Standard-Webhooks signature — never hand-roll it) and handles
order.paid: insert purchase → on conflict return → grant_credits RPC.
- Optional reconciliation function: verify a user's recent paid orders
against the Polar API and fulfill any the webhook missed (dev tunnels,
delivery failures). Same idempotency key makes it safe.
Full walkthrough with code: references/polar-setup.md.
5. Spending credits + caching expensive work
In any paid API route: authenticate, then call the spend_credits RPC (service
client) before doing the expensive work. Catch the insufficient_credits
error (P0001) and return a 402-style response.
For expensive, deterministic operations (LLM calls over an uploaded file),
cache results by content hash: store sha256(file_bytes) (+ any secondary
input hash) with the result JSON, and return the cached result at zero cost on
a repeat run. Store the result and the hash — never the raw uploaded bytes.
Schema and lookup pattern are in
references/credit-ledger.md.
6. Environment variables
Copy assets/starter-template/.env.example and fill in. Never commit real
values; the service-role key and webhook secret are server-only.
Audit checklist (existing projects)
When asked to audit a project against this architecture, verify each and
report findings with file/line references:
Reference files
| File |
Read when |
| references/credit-ledger.md |
Designing/adapting the schema, credit functions, monthly top-up, file-hash caching |
| references/polar-setup.md |
Polar dashboard setup, checkout, webhook handler, reconciliation |
| references/rls-patterns.md |
Writing or auditing RLS policies and privilege boundaries |
| references/auth-flow.md |
OAuth callback, profile provisioning, welcome-grant race handling |
Starter template
assets/starter-template/ is a copyable skeleton: SQL migrations, Supabase
clients, billing library, checkout/webhook/auth-callback routes, and
.env.example. When scaffolding, copy the files into the target project's
structure, rename the example credit packs to the product's real ones, and
walk the user through the Polar dashboard + env setup from
references/polar-setup.md.
1---2name: nextjs-supabase-polar-starter3description: Scaffold or audit a multi-tenant SaaS billing and auth stack built on Next.js, Supabase, and Polar.sh. Use when building a credit-based SaaS, adding a credit system / usage-based billing / token wallet to a Supabase app, integrating Polar.sh checkout or webhooks, setting up Merchant of Record payments from a region where Stripe, Paddle, or Lemon Squeezy are restricted or unavailable (Bangladesh, Pakistan, Nigeria, etc.), designing an append-only credit ledger with SECURITY DEFINER functions, wiring Google-only OAuth via Supabase Auth, or reviewing RLS policies for a multi-tenant billing schema. Covers server-authoritative credits, webhook idempotency, purchase reconciliation, and file-hash result caching.4---56# Next.js + Supabase + Polar.sh SaaS Starter78Production-tested architecture for a credit-based, multi-tenant SaaS. Extracted9from a live product; every pattern here has survived real payments and real10users. Use it to **scaffold** a new project or **audit** an existing one.1112## Core principles (non-negotiable)13141. **The server owns all credit math.** Balances live in Postgres and are15 mutated ONLY by two `SECURITY DEFINER` functions — `grant_credits` and16 `spend_credits` — callable only by the service role. Client code (and even17 normal server code) can never `UPDATE profiles SET credits = ...`.182. **Append-only ledger.** Every credit movement writes a `credit_ledger` row19 with `delta`, `reason`, `balance_after`, and `metadata`. Nothing is ever20 updated or deleted in the ledger. This is your audit trail and your21 debugging lifeline.223. **Credits are granted only from verified webhooks** (or authenticated23 reconciliation against the provider API) — never from a browser24 `success_url` redirect, which can be closed, refreshed, or forged.254. **Idempotency via unique constraint.** The webhook inserts a `purchases`26 row keyed by the provider's transaction id (UNIQUE) *before* granting27 credits. A redelivered webhook conflicts (`23505`) and no-ops.285. **RLS = read-your-own-rows only.** Users may `SELECT` their own profile,29 ledger, and purchases. All writes are revoked from `anon`/`authenticated`30 and flow through the service role or the definer functions.316. **The product catalog is server-authoritative.** The webhook maps a paid32 `product_id` back to a credit amount from a server-side catalog. It never33 trusts amounts or product info from client metadata.3435## Scaffolding workflow3637Work through these steps in order. Deep detail for each lives in `references/`.3839### 1. Database schema + credit functions4041Apply the migrations from `assets/starter-template/supabase/migrations/`:4243- `0001_profiles_credits.sql` — `profiles`, `credit_ledger`, `grant_credits`,44 `spend_credits`, signup trigger, RLS.45- `0002_purchases.sql` — `purchases` table with the UNIQUE idempotency key.46- `0003_monthly_topup.sql` — optional capped monthly free-credit refill47 (set-based, single transaction, driven by a cron).4849Read [references/credit-ledger.md](references/credit-ledger.md) for the full50rationale: row locking, why `balance_after` is stored, error codes, the51welcome-grant race, and how to adapt amounts/reasons to a new product.5253### 2. Supabase clients (three of them, strictly separated)5455From `assets/starter-template/src/lib/supabase/`:5657- `client.ts` — browser client (anon key, RLS enforced).58- `server.ts` — cookie-bound server client for Server Components/Actions59 (anon key, RLS enforced) **plus** `createServiceClient()` (service-role key,60 bypasses RLS, server-only, never imported into client code).61- `middleware.ts` — session refresh on every request.6263### 3. Auth: Google-only OAuth via Supabase6465Google-only sign-in avoids password handling entirely and works everywhere.66The critical piece is **idempotent profile provisioning**: a DB trigger on67`auth.users` INSERT plus an `ensure_profile()` RPC called from the OAuth68callback, so pre-existing accounts and race-y parallel logins all end up with69exactly one profile and exactly one welcome grant.7071See [references/auth-flow.md](references/auth-flow.md) for the callback route,72the provisioning function, and the welcome-grant guard.7374### 4. Polar.sh: checkout, webhook, reconciliation7576Polar.sh acts as **Merchant of Record** — it is the seller of record and77handles global tax, which is why this works from countries where Stripe and78Paddle won't onboard you. Setup order:79801. Create products (credit packs) in the Polar dashboard — sandbox first.812. Server-side catalog maps env-provided product ids → credit amounts.823. Checkout route (authenticated) creates a Polar checkout with83 `metadata.user_id` taken from the **session**, never the request body.844. Webhook route uses `Webhooks()` from `@polar-sh/nextjs` (verifies the85 Standard-Webhooks signature — never hand-roll it) and handles86 `order.paid`: insert purchase → on conflict return → `grant_credits` RPC.875. Optional reconciliation function: verify a user's recent paid orders88 against the Polar API and fulfill any the webhook missed (dev tunnels,89 delivery failures). Same idempotency key makes it safe.9091Full walkthrough with code: [references/polar-setup.md](references/polar-setup.md).9293### 5. Spending credits + caching expensive work9495In any paid API route: authenticate, then call the `spend_credits` RPC (service96client) *before* doing the expensive work. Catch the `insufficient_credits`97error (`P0001`) and return a 402-style response.9899For expensive, deterministic operations (LLM calls over an uploaded file),100cache results by content hash: store `sha256(file_bytes)` (+ any secondary101input hash) with the result JSON, and return the cached result at zero cost on102a repeat run. Store the result and the hash — never the raw uploaded bytes.103Schema and lookup pattern are in104[references/credit-ledger.md](references/credit-ledger.md#file-hash-result-caching).105106### 6. Environment variables107108Copy `assets/starter-template/.env.example` and fill in. Never commit real109values; the service-role key and webhook secret are server-only.110111## Audit checklist (existing projects)112113When asked to audit a project against this architecture, verify each and114report findings with file/line references:115116- [ ] No code path updates `profiles.credits` directly (grep for it) — only117 the two definer functions touch it.118- [ ] `grant_credits` / `spend_credits` have `EXECUTE` revoked from `public`,119 `anon`, `authenticated`; granted only to `service_role`.120- [ ] All functions use `security definer set search_path = public`.121- [ ] Ledger table has no UPDATE/DELETE path from any role; RLS enabled.122- [ ] Webhook verifies signatures via the provider SDK, not manually.123- [ ] Purchase insert precedes credit grant; provider txn id is UNIQUE;124 `23505` is treated as "already processed".125- [ ] Credits granted from webhook/reconciliation only — nothing on the126 `success_url` page mutates balances.127- [ ] Checkout `metadata.user_id` comes from the authenticated session.128- [ ] Product→credits mapping is server-side; webhook ignores client-supplied129 amounts.130- [ ] Service-role client is never imported into client components131 (grep imports of the service client from `"use client"` files).132- [ ] RLS: every user-facing table has `SELECT using (auth.uid() = user_id)`133 (or equivalent) and writes revoked. See134 [references/rls-patterns.md](references/rls-patterns.md).135136## Reference files137138| File | Read when |139|------|-----------|140| [references/credit-ledger.md](references/credit-ledger.md) | Designing/adapting the schema, credit functions, monthly top-up, file-hash caching |141| [references/polar-setup.md](references/polar-setup.md) | Polar dashboard setup, checkout, webhook handler, reconciliation |142| [references/rls-patterns.md](references/rls-patterns.md) | Writing or auditing RLS policies and privilege boundaries |143| [references/auth-flow.md](references/auth-flow.md) | OAuth callback, profile provisioning, welcome-grant race handling |144145## Starter template146147`assets/starter-template/` is a copyable skeleton: SQL migrations, Supabase148clients, billing library, checkout/webhook/auth-callback routes, and149`.env.example`. When scaffolding, copy the files into the target project's150structure, rename the example credit packs to the product's real ones, and151walk the user through the Polar dashboard + env setup from152[references/polar-setup.md](references/polar-setup.md).