Documentation

Clone to production, the guide.

Everything you need to set up, configure, and ship the GoToMarketFit boilerplate — a multi-tenant B2B SaaS foundation on Next.js 15 and Supabase, from git clone to a live app on Vercel.

/ getting started

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).
  • Rolesowner / 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

Next.js 15App Router · RSC
TypeScriptstrict · no ORM
SupabasePostgres · Auth · RLS
@supabase/ssrserver + browser clients
Stripeper-org billing
Upstash Redisauth rate limiting
Resendemail + bounce webhooks
Tailwind v421 runtime themes
/ getting started

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, or bun.
  • 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 supabase CLI plus Docker (for CLI migrations and the pgTAP database tests) and the stripe CLI (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.
You do not need to be a backend expert. The hard parts — auth, billing, RLS, multi-tenancy — are already built and tested. If you can run a few terminal commands and edit an .env file, AI assistants like Cursor or Claude will walk you through the rest.
/ getting started

Quick start

The fastest path to a running app is: clone, install, point it at a Supabase project, and run the schema.

terminal
# Use the exact clone URL of the repository you were granted access to
git clone git@github.com:your-org/saas-multitenant-boilerplate.git my-saas
cd my-saas

npm install

# Create your local env file
cp .env.local.example .env.local

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.
terminal
npm run dev

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.

The repo name above is a placeholder — use the clone URL shown on your granted repository. Treat .env.local.example as the authoritative variable list for the version you received.
/ configuration

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.

VariableScopePurpose
NEXT_PUBLIC_SUPABASE_URLpublicYour Supabase project URL.
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEYpublicSupabase publishable key — safe for the browser, gated by RLS.
SUPABASE_SERVICE_ROLE_KEYserver-onlyAdmin key that bypasses RLS. Used only by the Stripe webhook writer — never expose it.
UPSTASH_REDIS_REST_URLserver-onlyUpstash REST URL for the rate limiter. Required in production.
UPSTASH_REDIS_REST_TOKENserver-onlyUpstash REST token.
STRIPE_SECRET_KEYserver-onlyStripe API key for creating Checkout sessions.
STRIPE_WEBHOOK_SECRETserver-onlySigning secret used to verify incoming Stripe webhooks.
NEXT_PUBLIC_STRIPE_PRICE_ID_PROpublicPrice ID for the Pro plan. Not secret — Checkout exposes it anyway.
STRIPE_TRIAL_DAYSserver-onlyFree-trial length before first charge. 0 (or omit) disables trials.
RESEND_API_KEYserver-onlyResend API key for outgoing email.
RESEND_FROM_EMAILserver-onlyVerified sender address for outgoing email.
RESEND_WEBHOOK_SECRETserver-onlySigning secret for the Resend bounce/complaint webhook.
NEXT_PUBLIC_APP_URLpublicThe app's public base URL — used for redirects and email links.
NEXT_PUBLIC_APP_NAMEpublicDisplay name shown throughout the UI.
NEXT_PUBLIC_COMPANY_NAMEpublicLegal/company name shown in footer and legal pages.
NEXT_PUBLIC_CONTACT_EMAILpublicPublic contact address.
Anything without the 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.
/ configuration

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:

terminal
npm install -g supabase
supabase link --project-ref <your-project-ref>
supabase db push

The migrations, in order:

supabase/migrations/
00001_core_schema.sql          profiles, organizations, memberships, projects, invitations
00002_auto_tenant_trigger.sql  handle_new_user() — auto-provisions a workspace on signup
00003_rls_policies.sql         RLS + get_user_organization_ids() / get_user_role_in_org()
00004_accept_invitation_rpc.sql  race-safe, oracle-resistant accept flow
00005_billing.sql              subscriptions (webhook is the only writer)
00006_grant_table_access.sql   role grants
00007_avatar_storage.sql       avatar storage bucket + policies
00008_email_suppressions.sql   bounce/complaint suppression list
00009_stripe_disputes.sql      dispute records for chargeback tracking

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

New Supabase projects require email confirmation before a session is issued. For local development you can turn this off under Authentication → Providers → Email → Confirm email so signup logs you in immediately. Leave it on in production.
/ configuration

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/login route handler.
  • Magic linkPOST /api/auth/magic-link generates a Supabase link server-side and emails it via Resend (also rate-limited).
  • Google OAuth — a client-side signInWithOAuth redirect. 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:

Authorized redirect URI
https://<your-project-ref>.supabase.co/auth/v1/callback

Then, under Authentication → URL Configuration, set your Site URL and add both http://localhost:3000 and your production domain to the redirect allow-list.

/ configuration

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

terminal
stripe listen --forward-to localhost:3000/api/stripe/webhook
# copy the printed whsec_... into STRIPE_WEBHOOK_SECRET
stripe trigger checkout.session.completed

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.created and customer.subscription.trial_will_end (in addition to the billing events).
/ configuration

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.

Without these, rate limiting is skipped in development (with a console warning) but throws on startup in production — it is not optional in prod.
/ configuration

Transactional email

Resend sends signup confirmations, magic links, and invitations, and receives bounce/complaint webhooks so you stop mailing dead addresses.

  • Set RESEND_API_KEY and RESEND_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 to noreply@yourdomain.com for production.
  • For bounce handling, add a Resend webhook at <APP_URL>/api/resend/webhook, subscribe to email.bounced and email.complained, and put its signing secret in RESEND_WEBHOOK_SECRET. Suppressed addresses are recorded in the email_suppressions table.
Without 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.
/ how it works

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.users gets a new row on signup.
  • The handle_new_user() trigger creates a profiles row, a personal organizations row, and a memberships row making the user owner.
  • Every query against tenant tables is filtered by get_user_organization_ids() — a SECURITY DEFINER STABLE function, 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.

supabase/migrations/00003_rls_policies.sql
create policy "projects: select member"
  on public.projects for select
  using (organization_id in (select public.get_user_organization_ids()));
/ how it works

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.

supabase/migrations/00003_rls_policies.sql
create policy "projects: update admin"
  on public.projects for update
  using (
    organization_id in (select public.get_user_organization_ids())
    and public.get_user_role_in_org(organization_id) in ('owner', 'admin')
  );
/ how it works

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 a SECURITY DEFINER function that uses FOR 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).

/ build & ship

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 projects policies in 00003_rls_policies.sql, swap the table name, and adjust the role checks for your access model.
  • Add the table's Row / Insert / Update / Relationships shape to Database['public']['Tables'] in src/types/database.ts.
  • Write a hook following src/hooks/useProjects.ts.
Do not omit 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.
/ build & ship

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 under supabase/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.
/ build & ship

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_URL to 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 into STRIPE_WEBHOOK_SECRET, and subscribe to the billing events plus charge.dispute.created and customer.subscription.trial_will_end.
  • In Resend, add the bounce webhook at https://yourdomain.com/api/resend/webhook.
terminal
# Or deploy from the CLI
npm install -g vercel
vercel --prod
/ build & ship

Project structure

The top-level map. Treat the repository's own README as authoritative for the version you received.

tree
supabase/migrations/      Ordered SQL migrations (schema, trigger, RLS, RPC)
quickstart.sql            The same migrations concatenated for a single paste
src/app/(marketing)/      Public landing page + blog
src/app/(auth)/           Login, signup, auth callback
src/app/invite/[token]/   Invitation acceptance flow
src/app/(app)/            Authenticated app: dashboard, projects, settings
src/app/api/              Route handlers: auth, billing, stripe, resend, invitations
src/components/           Auth forms, layout (sidebar, org switcher), UI primitives
src/hooks/                Data-fetching hooks (one per resource)
src/context/              Active-organization state
src/lib/supabase/         Browser + server Supabase client factories
src/types/database.ts     Hand-written Database type (see note above)
/ build & ship

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.