Introduction
GoToMarketFitis a production-grade foundation for any B2B SaaS that needs organizations, team members, roles, and invitations. The hard part of multi-tenancy isn't the React forms — it's making sure Team A can never see Team B's data. This kit solves that at the database layer with Postgres Row-Level Security, not in application code where one missed WHERE clause becomes a breach.
When you purchase, you are added to the private GitHub repository. Clone it like any other project. What is inside:
- Organizations — every user gets a personal workspace automatically on signup, provisioned by a Postgres trigger (no webhook, no background job).
- Roles —
owner/admin/member, enforced by RLS, not just hidden buttons in the UI. - Invitations — secure, single-use, time-boxed (7-day) tokens with a race-safe, oracle-resistant accept flow.
- Authentication — email/password with confirmation, Google OAuth, and magic links; login and signup are rate-limited server-side.
- Billing — per-organization Stripe subscriptions, kept in sync entirely by webhooks, with chargeback-reduction hardening built in.
- UI — a dark, modern interface on Tailwind v4 with a set of typed primitives and 21 runtime-swappable themes.
The stack
Prerequisites
Free tiers are enough to run the whole kit end to end. You will need the tooling and accounts below.
On your machine
- Node.js 20 LTS or newer and a package manager —
npm,pnpm, orbun. - Git, and access to the private repo (you were invited to the GitHub org at checkout — accept the invite from your email or GitHub notifications).
- Optional: the
supabaseCLI plus Docker (for CLI migrations and the pgTAP database tests) and thestripeCLI (for local webhook forwarding).
Accounts you will connect
- Supabase — Postgres database and auth. Required.
- Upstash Redis — backs auth rate limiting. Optional in development, required in production.
- Stripe — per-organization subscription billing.
- Resend — transactional email (confirmations, magic links, invitations, bounce handling).
- Vercel (or any Next.js host) — production hosting.
- Google Cloud — only if you want to enable the Google sign-in button.
.env file, AI assistants like Cursor or Claude will walk you through the rest.Quick start
The fastest path to a running app is: clone, install, point it at a Supabase project, and run the schema.
At minimum, fill in NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY from your Supabase project (Stripe, Upstash, and Resend can come later). Then create the database and start the dev server:
- Create a project at supabase.com.
- Open the SQL Editor, paste the entire contents of
quickstart.sql, and run it — this creates every table, trigger, RLS policy, and RPC in one shot.
Visit http://localhost:3000, sign up, and you land in a dashboard for a workspace named after you — created automatically by the handle_new_user() trigger.
.env.local.example as the authoritative variable list for the version you received.Environment variables
Configuration lives in .env.localduring development and in your host's environment settings in production. Variables prefixed with NEXT_PUBLIC_ are inlined into the browser bundle; everything else stays server-side only.
| Variable | Scope | Purpose |
|---|---|---|
NEXT_PUBLIC_SUPABASE_URL | public | Your Supabase project URL. |
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY | public | Supabase publishable key — safe for the browser, gated by RLS. |
SUPABASE_SERVICE_ROLE_KEY | server-only | Admin key that bypasses RLS. Used only by the Stripe webhook writer — never expose it. |
UPSTASH_REDIS_REST_URL | server-only | Upstash REST URL for the rate limiter. Required in production. |
UPSTASH_REDIS_REST_TOKEN | server-only | Upstash REST token. |
STRIPE_SECRET_KEY | server-only | Stripe API key for creating Checkout sessions. |
STRIPE_WEBHOOK_SECRET | server-only | Signing secret used to verify incoming Stripe webhooks. |
NEXT_PUBLIC_STRIPE_PRICE_ID_PRO | public | Price ID for the Pro plan. Not secret — Checkout exposes it anyway. |
STRIPE_TRIAL_DAYS | server-only | Free-trial length before first charge. 0 (or omit) disables trials. |
RESEND_API_KEY | server-only | Resend API key for outgoing email. |
RESEND_FROM_EMAIL | server-only | Verified sender address for outgoing email. |
RESEND_WEBHOOK_SECRET | server-only | Signing secret for the Resend bounce/complaint webhook. |
NEXT_PUBLIC_APP_URL | public | The app's public base URL — used for redirects and email links. |
NEXT_PUBLIC_APP_NAME | public | Display name shown throughout the UI. |
NEXT_PUBLIC_COMPANY_NAME | public | Legal/company name shown in footer and legal pages. |
NEXT_PUBLIC_CONTACT_EMAIL | public | Public contact address. |
NEXT_PUBLIC_ prefix is a secret. The SUPABASE_SERVICE_ROLE_KEY bypasses row-level security entirely — keep it out of client code, out of git, and never behind a NEXT_PUBLIC_ name. Restart the dev server after editing .env.local; Next.js only reads env files at startup.Supabase & the schema
Supabase provides both authentication and the Postgres database. Create a project, load the schema, then copy your keys.
1 — Load the schema
The fastest route: open the SQL Editor in your Supabase dashboard, paste the entire quickstart.sql, and run it once. It creates every table, trigger, RLS policy, and RPC. The same SQL also exists as ordered files under supabase/migrations/ if you prefer the CLI:
The migrations, in order:
2 — Copy your API credentials
Under Settings → API, copy the Project URL and publishable key into NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY. Copy the service_role key into SUPABASE_SERVICE_ROLE_KEY (server-only — it is used solely by the Stripe webhook).
Authentication
Three sign-in methods ship wired up, all routed through Supabase Auth:
- Email & password — signup with email confirmation, and login through the rate-limited
/api/auth/loginroute handler. - Magic link —
POST /api/auth/magic-linkgenerates a Supabase link server-side and emails it via Resend (also rate-limited). - Google OAuth — a client-side
signInWithOAuthredirect. The button is already in the login and signup forms; both OAuth and email-confirmation links return to/(auth)/callback, which exchanges the code for a session.
Email/password and magic link work as soon as Supabase is connected. To turn on the Google button, enable the Google provider under Authentication → Providers → Google in Supabase and add your client ID/secret from the Google Cloud Console, with this authorized redirect URI:
Then, under Authentication → URL Configuration, set your Site URL and add both http://localhost:3000 and your production domain to the redirect allow-list.
Stripe billing
Billing is per-organization, not per-user — one subscription per tenant, managed only by the org owner. Crucially, the webhook is the only writer to the subscriptions table: there is no RLS policy letting the app write it, so the row can never drift from what Stripe actually thinks the state is.
1 — Keys and price
Create a product and recurring price in the Stripe Dashboard, then set STRIPE_SECRET_KEY, NEXT_PUBLIC_STRIPE_PRICE_ID_PRO, and SUPABASE_SERVICE_ROLE_KEY. Use test-mode keys while developing.
2 — Forward webhooks locally
The flow
The owner clicks Upgrade on /settings/billing, which calls /api/billing/checkout to create a hosted Checkout session. On success, Stripe sends checkout.session.completed to /api/stripe/webhook, which verifies the signature and writes the subscription via the service-role client. customer.subscription.updated / .deleted and invoice.payment_failed keep the row in sync from there.
Chargeback hardening
Checkout already sets billing_address_collection: 'required' (AVS) and automatic 3D Secure, and the webhook records charge.dispute.created events into a stripe_disputes table (you have 7 days to respond before a dispute auto-closes against you). Three things you still must do in the Stripe Dashboard:
- Set a clear statement descriptor (your app name, not your LLC name).
- Enable receipt emails on successful payments.
- Subscribe your webhook endpoint to
charge.dispute.createdandcustomer.subscription.trial_will_end(in addition to the billing events).
Rate limiting
/api/auth/login and /api/auth/signup are protected by Upstash Redis sliding-window limiters — 5 attempts/min per email+IP and 20/min per IP for login, 3 attempts/10 min per IP for signup.
This is why sign-in and sign-up go through server route handlers instead of calling supabase.auth.* from the browser: the limiter has to run against a trusted IP beforea password attempt reaches Supabase, and a client-side check can't be trusted.
Create a free Redis database at console.upstash.com, copy the REST URL and token from its REST API tab, and set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN.
Transactional email
Resend sends signup confirmations, magic links, and invitations, and receives bounce/complaint webhooks so you stop mailing dead addresses.
- Set
RESEND_API_KEYandRESEND_FROM_EMAIL. Until you verify a domain you can use Resend's shared sender (onboarding@resend.dev), which only delivers to your own Resend-account email — fine for dev. Verify a domain and switch tonoreply@yourdomain.comfor production. - For bounce handling, add a Resend webhook at
<APP_URL>/api/resend/webhook, subscribe toemail.bouncedandemail.complained, and put its signing secret inRESEND_WEBHOOK_SECRET. Suppressed addresses are recorded in theemail_suppressionstable.
RESEND_API_KEY, signup confirmation links are printed to the server logs and invite emails are skipped — but the invite link is still shown in the UI, so you can keep building.Tenant isolation (RLS)
Nothing in the frontend decides what a user can see — the hooks in src/hooks/ issue plain select() / insert()calls with no manual filtering by organization, and the database enforces the rest. If you removed every RLS policy and ran the app, every user would instantly see every organization's data. That is the test to run if you ever doubt the isolation is real.
The chain, end to end:
auth.usersgets a new row on signup.- The
handle_new_user()trigger creates aprofilesrow, a personalorganizationsrow, and amembershipsrow making the userowner. - Every query against tenant tables is filtered by
get_user_organization_ids()— aSECURITY DEFINER STABLEfunction, so it runs once per query, not once per row.
That helper design is the whole trick. Writing the membership subquery inline in every policy would make Postgres re-evaluate it for every row scanned (an N+1 problem) and would recurse infinitely on the memberships table's own policy. SECURITY DEFINER bypasses RLS inside the function to break the recursion; STABLE lets the planner cache the result per query.
Roles & permissions
Every membership carries one of three roles, enforced in the RLS policies themselves via get_user_role_in_org() — not merely hidden in the UI.
- Owner— full control, including billing. Each organization's personal workspace starts with its creator as owner.
- Admin — manages members, invitations, and tenant data.
- Member — reads and creates within the organization, without administrative writes.
The demo projects table shows the pattern: SELECT and INSERT are open to any member of the org, while UPDATE and DELETE additionally require an owner/admin role. Adjust these role checks to fit your own access model.
Invitations
Owners and admins invite teammates by email. Each invitation is a row in invitations with a random UUID token, and it is safe by construction:
- Single-use & time-boxed — tokens expire after 7 days and are consumed on accept.
- Race-safe — the
accept_invitation(token)RPC is aSECURITY DEFINERfunction that usesFOR UPDATE SKIP LOCKEDto validate, lock, and insert the membership in one transaction, so two simultaneous accepts can't both succeed. - Oracle-resistant— it returns identical errors for "not found", "expired", and "already used", so the endpoint can't be used to enumerate valid invites.
The acceptance UI lives at src/app/invite/[token]/, and the invitation email is delivered through Resend (see Transactional email).
Add your own resource
The projects table exists purely to demonstrate the full read/write/RLS round-trip. To add your own tenant-scoped table, follow the same five steps:
- Create the table with an
organization_id UUID NOT NULL REFERENCES organizations(id)column, and add an index on it. - Copy the four
projectspolicies in00003_rls_policies.sql, swap the table name, and adjust the role checks for your access model. - Add the table's
Row/Insert/Update/Relationshipsshape toDatabase['public']['Tables']insrc/types/database.ts. - Write a hook following
src/hooks/useProjects.ts.
Relationships (or Views) from the hand-written database type — leaving them out silently degrades every query on that table to type never with no runtime error.Testing
Two suites, run separately, both wired into CI on every push and PR:
npm test— Vitest unit tests for pure logic (rate-limit IP parsing, Stripe webhook → row mapping). No external services required.npm run test:db— pgTAP tests undersupabase/tests/database/that exercise the RLS policies as real authenticated users (not superuser), proving the tenant-isolation and oracle-resistance claims. Requires the Supabase CLI and Docker (supabase db start) plus a one-time setup step.
Deploying
Any Next.js host works; Vercel is the path of least resistance. The steps:
- Import the repository and accept the detected Next.js settings.
- Set every variable from Environment variables in Production (and Preview). Set
NEXT_PUBLIC_APP_URLto your real domain. Remember Upstash is required in production. - In Supabase, point the Authentication → URL Configuration redirect URLs at your production domain (and update the Google redirect URI if you enabled OAuth).
- In Stripe, add a webhook endpoint at
https://yourdomain.com/api/stripe/webhook, copy its signing secret intoSTRIPE_WEBHOOK_SECRET, and subscribe to the billing events pluscharge.dispute.createdandcustomer.subscription.trial_will_end. - In Resend, add the bounce webhook at
https://yourdomain.com/api/resend/webhook.
Project structure
The top-level map. Treat the repository's own README as authoritative for the version you received.
Troubleshooting
Queries return nothing, or “permission denied”
Row-level security is doing its job. Confirm the signed-in user is a member of the organization whose data you are querying, and that the request carries the session — not a bare unauthenticated client.
The app throws on startup in production
Rate limiting is required in production. Set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN.
Stripe webhook signature verification fails
STRIPE_WEBHOOK_SECRET does not match the endpoint sending the event. Local stripe listen and each dashboard endpoint have different secrets — use the one for the endpoint you are actually hitting.
Emails don’t arrive (or land in spam)
Verify your sending domain in Resend and publish its DNS records. Without RESEND_API_KEY, confirmation links go to the server logs and invites are skipped (the link still shows in-UI).
Google sign-in returns redirect_uri_mismatch
Enable the Google provider in Supabase and make sure the redirect URI in Google Cloud exactly equals your Supabase callback URL.
A new table's queries are typed never
You omitted Relationships (or Views) when adding the table to src/types/database.ts. Add them back.
Still stuck? If your tier includes support, email hello@gotomarketfit.com — responses are usually under a day.