# Nextjs Supabase Polar Starter

> 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.

- Skill: `mahfuzurrahman01/nextjs-supabase-polar-starter` (Agent Skill, multi-file: 21 files)
- Install (CLI): `npx skillmds@latest add mahfuzurrahman01/nextjs-supabase-polar-starter`
- Raw SKILL.md: https://api.skillmd.com/api/skills/mahfuzurrahman01/nextjs-supabase-polar-starter/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: mahfuzurrahman01 (https://skillmd.com/u/mahfuzurrahman01)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/mahfuzurrahman01/nextjs-supabase-polar-starter

---


# 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)

1. **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 = ...`.
2. **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.
3. **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.
4. **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.
5. **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.
6. **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](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](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:

1. Create products (credit packs) in the Polar dashboard — sandbox first.
2. Server-side catalog maps env-provided product ids → credit amounts.
3. Checkout route (authenticated) creates a Polar checkout with
   `metadata.user_id` taken from the **session**, never the request body.
4. 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.
5. 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](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](references/credit-ledger.md#file-hash-result-caching).

### 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:

- [ ] No code path updates `profiles.credits` directly (grep for it) — only
      the two definer functions touch it.
- [ ] `grant_credits` / `spend_credits` have `EXECUTE` revoked from `public`,
      `anon`, `authenticated`; granted only to `service_role`.
- [ ] All functions use `security definer set search_path = public`.
- [ ] Ledger table has no UPDATE/DELETE path from any role; RLS enabled.
- [ ] Webhook verifies signatures via the provider SDK, not manually.
- [ ] Purchase insert precedes credit grant; provider txn id is UNIQUE;
      `23505` is treated as "already processed".
- [ ] Credits granted from webhook/reconciliation only — nothing on the
      `success_url` page mutates balances.
- [ ] Checkout `metadata.user_id` comes from the authenticated session.
- [ ] Product→credits mapping is server-side; webhook ignores client-supplied
      amounts.
- [ ] Service-role client is never imported into client components
      (grep imports of the service client from `"use client"` files).
- [ ] RLS: every user-facing table has `SELECT using (auth.uid() = user_id)`
      (or equivalent) and writes revoked. See
      [references/rls-patterns.md](references/rls-patterns.md).

## Reference files

| File | Read when |
|------|-----------|
| [references/credit-ledger.md](references/credit-ledger.md) | Designing/adapting the schema, credit functions, monthly top-up, file-hash caching |
| [references/polar-setup.md](references/polar-setup.md) | Polar dashboard setup, checkout, webhook handler, reconciliation |
| [references/rls-patterns.md](references/rls-patterns.md) | Writing or auditing RLS policies and privilege boundaries |
| [references/auth-flow.md](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](references/polar-setup.md).

