Recipe · checked November 6, 2026
B2B SaaS with auth, billing and email
A multi-tenant web app sold to companies: organizations with roles, subscription billing per organization, and transactional email. Built on Next.js, Postgres, hosted auth and Stripe.
Fits when
- Customers are companies, and several people from one company share an account.
- You charge a recurring subscription, per organization or per seat.
- You need invites, roles, receipts and password-less or SSO sign-in.
Not for
- Consumer apps with one user per account (drop the organization layer).
- Marketplaces that pay out to sellers (you need Stripe Connect and a different money flow).
- Apps that move or hold customer money (fintech ledgers need licensing review first).
The stack
| Category | Pick | Why | Pick something else when |
|---|---|---|---|
| Hosting | Vercel | Native Next.js hosting with preview deployments. Use Pro once you charge customers; Hobby is non-commercial. |
|
| Database | Neon | Plain Postgres that scales to zero, with a database branch for each preview deployment. | |
| Auth | WorkOS AuthKit | Organizations are the core model, 1M monthly users are free, and enterprise SSO is ready when a large customer asks. |
|
| Payments | Stripe | Subscriptions, seats, invoices and a hosted customer portal at the lowest base fee. |
|
| Resend | Simple API with React Email templates and 3,000 free emails a month. |
| |
| Secretsoptional | Infisical | One source of truth for env vars across dev, preview and production, synced to Vercel. |
|
| DNS | Cloudflare DNS | Free DNS with a good API; holds the app and email records. |
|
| Monitoring | Sentry | Error tracking with source maps from the official Next.js SDK. |
|
| Analyticsoptional | PostHog | Group analytics reports usage per customer account, so you can see which companies use which features. |
|
| Storageoptional | Cloudflare R2 | S3-compatible uploads and exports with no egress fees. |
|
About $40/month at 1,000 users in tool bills: Vercel $20 + Resend $20; $0 for Neon, WorkOS AuthKit, Stripe, Cloudflare DNS, Sentry. Usage profile: 1,000 monthly active users with light use: about 100,000 requests, 3,000 emails, 200,000 analytics events and 5 GB of stored files a month, and a database under 1 GB. AI apps add 50 questions per user a month at about 4,000 input and 500 output tokens each (200M input and 25M output tokens). Internal tools are priced for 10 staff instead.
Catches that change the plan
- Vercel's Hobby plan is for personal, non-commercial use; move to Pro ($20 a month per developer seat) before the first paying customer. Source
- Enterprise SSO and directory sync are priced per connection in WorkOS ($125 each for the first 1 to 15); turn them on only when a customer asks. Source
- Stripe isn't a merchant of record by default: registering for and filing sales tax and VAT is yours. Paddle is the alternative that files it for you. Source
Setup steps
Scaffold the app
Start from a typed Next.js App Router project with strict TypeScript and a single env schema.
- Create the app with TypeScript, ESLint and the App Router.
- Add one env module that validates every variable at startup (zod), and import env only from there.
- Commit a .env.example with every variable name and no values.
npx create-next-app@latest my-saas --ts --eslint --app --src-dir npm i zodsrc/env.tsimport { z } from "zod"; export const env = z .object({ DATABASE_URL: z.string().url(), WORKOS_API_KEY: z.string().min(1), WORKOS_CLIENT_ID: z.string().min(1), WORKOS_COOKIE_PASSWORD: z.string().min(32), STRIPE_SECRET_KEY: z.string().startsWith("sk_"), STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"), RESEND_API_KEY: z.string().startsWith("re_"), APP_URL: z.string().url(), }) .parse(process.env);Done when: The app boots locally and fails fast with a clear message when a variable is missing.
Model tenancy in Postgres
Every customer is an organization. Every row that belongs to a customer carries organization_id, and every query filters by it.
- Create a Neon project with a main branch for production and a dev branch for local work.
- Create organizations, users, memberships (user, organization, role) and an audit_log table.
- Add organization_id to every tenant-owned table, with an index, and a foreign key to organizations.
- Put all tenant queries behind one data-access layer that requires an organization id, or enable Postgres row-level security keyed on it.
- Use a migration tool (Drizzle Kit, Prisma Migrate or plain SQL files) and run migrations in CI, not by hand.
db/001_tenancy.sqlcreate table organizations ( id uuid primary key default gen_random_uuid(), name text not null, auth_org_id text unique not null, -- the auth provider's organization id stripe_customer_id text unique, plan text not null default 'free', subscription_status text, -- mirrored from Stripe webhooks current_period_end timestamptz, created_at timestamptz not null default now() ); create table users ( id uuid primary key default gen_random_uuid(), auth_user_id text unique not null, email text not null, created_at timestamptz not null default now() ); create table memberships ( organization_id uuid not null references organizations(id) on delete cascade, user_id uuid not null references users(id) on delete cascade, role text not null check (role in ('owner','admin','member')), primary key (organization_id, user_id) ); create table audit_log ( id bigserial primary key, organization_id uuid not null references organizations(id) on delete cascade, actor_user_id uuid references users(id), action text not null, details jsonb not null default '{}', created_at timestamptz not null default now() ); create index on audit_log (organization_id, created_at desc); create table stripe_events ( -- webhook idempotency id text primary key, type text not null, processed_at timestamptz not null default now() );Done when: A test that queries as organization A can never read organization B's rows.
Docs: neon.com
Add auth with organizations
Use the auth provider for sign-in and organization membership; keep your own users and memberships tables in sync for joins and permissions.
- Install the AuthKit Next.js SDK and add the callback route and session middleware it documents.
- On first sign-in, upsert the user and, if they have no organization, create one and make them owner.
- Store the provider's organization id on your organizations row (auth_org_id).
- Build invites: an owner or admin invites by email; the invite carries the role.
- Check the role on the server for every mutating action; never trust a role sent from the browser.
- Leave SSO off until a customer asks; turning it on is per organization, priced per connection.
npm i @workos-inc/authkit-nextjsDone when: Two users in one organization see the same data; a user in another organization sees none of it.
Docs: workos.com, workos.com
Add subscription billing per organization
Bill the organization, not the person. Stripe is the source of truth for subscription state; your database mirrors it from webhooks.
- Create Products and Prices in the Stripe dashboard (test mode first). Use per-seat quantity if you price by seat.
- Create one Stripe Customer per organization and store stripe_customer_id on the organization.
- Start upgrades with a Checkout Session in subscription mode, passing the organization id in metadata.
- Use the Stripe customer portal for plan changes, cards and invoices instead of building those screens.
- Handle webhooks in one route: verify the signature on the raw body, skip event ids already in stripe_events, then update the organization.
- Handle at least checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted and invoice.payment_failed.
- Gate features on the organization's mirrored plan and subscription_status on the server, never on a client flag.
- Turn on Stripe Tax if you sell where you're registered, or choose a merchant of record instead (see alternatives).
npm i stripe stripe listen --forward-to localhost:3000/api/stripe/webhooksrc/app/api/stripe/webhook/route.tsimport Stripe from "stripe"; import { env } from "@/env"; import { db } from "@/db"; const stripe = new Stripe(env.STRIPE_SECRET_KEY); export async function POST(req: Request) { const body = await req.text(); // raw body: required for signature checks const signature = req.headers.get("stripe-signature") ?? ""; let event: Stripe.Event; try { event = stripe.webhooks.constructEvent(body, signature, env.STRIPE_WEBHOOK_SECRET); } catch { return new Response("bad signature", { status: 400 }); } // Idempotency: Stripe retries, and events can arrive out of order. const fresh = await db.recordStripeEvent(event.id, event.type); if (!fresh) return new Response("ok"); switch (event.type) { case "customer.subscription.created": case "customer.subscription.updated": case "customer.subscription.deleted": { const sub = event.data.object; // Re-read from Stripe rather than trusting event order. const latest = await stripe.subscriptions.retrieve(sub.id); await db.syncSubscription(String(latest.customer), latest); break; } case "invoice.payment_failed": await db.flagPaymentFailed(String(event.data.object.customer)); break; } return new Response("ok"); }Done when: In test mode, a checkout upgrades the organization, a cancel in the portal downgrades it, and replaying the same webhook changes nothing.
Send transactional email
Send invites, payment-failed notices and account email from a verified subdomain.
- Verify a sending subdomain (for example mail.yourdomain.com) in Resend and add its SPF and DKIM records in DNS.
- Add a DMARC record for the root domain, starting at p=none with reporting, then tighten.
- Write templates with React Email: invite, welcome, payment failed, and plan changed.
- Let Stripe send receipts and invoices; don't duplicate them.
- Send from a server action or a queue, never from the browser, and log the message id against the organization.
npm i resend @react-email/componentsDone when: An invite email lands in the inbox (not spam) for Gmail and Outlook test accounts.
Docs: resend.com, resend.com
Manage secrets per environment
Keep one copy of each secret per environment and sync it to the host; never commit secrets.
- Create development, preview and production environments in Infisical.
- Use Stripe test keys in development and preview, and live keys in production only.
- Sync each environment to the matching Vercel environment.
- Add a pre-commit secret scan (for example gitleaks) and rotate any key that was ever committed.
Done when: A fresh clone runs with secrets pulled from Infisical and no .env file in git history.
Docs: infisical.com
Connect the domain
Point the app's domain at the host and keep email records in the same DNS zone.
- Add the domain to the Vercel project and create the records Vercel shows in Cloudflare DNS.
- Set those records to DNS only (not proxied) unless you deliberately want Cloudflare's proxy in front.
- Keep the Resend SPF, DKIM and DMARC records in the same zone.
Done when: The apex and www load over HTTPS, and email authentication checks pass.
Docs: vercel.com
Add monitoring and analytics
Know about errors before customers report them, and measure usage per customer account.
- Install the Sentry Next.js SDK with the wizard and upload source maps in production builds.
- Tag every Sentry event with organization id, never with email or other personal data.
- Add an uptime check on a /api/health route.
- Install PostHog, identify users, and set the organization as a group so usage rolls up per customer.
npx @sentry/wizard@latest -i nextjs npm i posthog-jsDone when: A thrown test error appears in Sentry with a readable stack trace and the organization tag.
Docs: docs.sentry.io, posthog.com
Set a security baseline
The things business customers ask about in their first security questionnaire.
- Write an audit_log row for sign-ins, invites, role changes, billing changes and data exports.
- Rate-limit sign-in, invite and webhook routes.
- Use a least-privilege database role for the app; keep the owner role for migrations.
- Confirm point-in-time restore is available on the database plan, and test a restore once.
- Add a way for an owner to export and delete their organization's data.
Done when: You can answer: who changed what, when; how to restore yesterday's data; and how to delete a customer.
Ship with preview environments
Every pull request gets its own deployment and database branch; production deploys from main.
- Connect the Git repo to Vercel so each pull request gets a preview deployment.
- Use the Neon integration or a CI step to create a database branch per preview and run migrations on it.
- Move the Vercel project to Pro before the first paying customer; Hobby is non-commercial.
- Switch Stripe to live mode only in the production environment.
Done when: A pull request shows a working preview with its own data, and merging deploys production.
Docs: neon.com
Best practices
- Bill the organization, not the user; seats are a quantity on the organization's subscription.
- Treat Stripe as the source of truth for billing and mirror it from webhooks; never set a plan from the client.
- Make webhook handlers idempotent and re-read objects from Stripe instead of trusting event order.
- Filter every tenant query by organization id in one shared layer, and test cross-tenant access.
- Keep test and live keys in separate environments so a preview can never charge a real card.
- Store provider ids (auth org id, Stripe customer id) on your rows so you can reconcile both ways.
- Write an audit log from day one; adding it later means no history for the first customers.
Pitfalls
- Billing per user instead of per organization, then rebuilding billing when a second teammate joins.
- Parsing the webhook body as JSON before verifying the signature (signature checks need the raw body).
- Granting access on the Checkout redirect instead of on the webhook; redirects can be faked or skipped.
- Launching a paid product on a free hosting plan that is non-commercial only.
- Sending email from the root domain with no DMARC, then landing in spam.
Costs and obligations
- Card fees: 2.9% + 30¢ per domestic card charge, plus 0.7% of billing volume for Stripe Billing subscriptions. At $2,000 a month in subscriptions from 40 invoices: $58 + $12 + $14 = about $84.
- Keeping card entry on Stripe-hosted Checkout or Elements keeps your PCI DSS scope small; it doesn't remove it.
- Business customers often ask for SOC 2. This stack makes the evidence easier to gather, but SOC 2 is an audit of your company, not of your vendors.
- If you sell to EU customers, you need a data processing agreement with each vendor that handles personal data, and a privacy policy that lists them.
- Fintech products that hold, move or lend money may need licenses or a regulated partner. This recipe covers SaaS billing only, not money movement.
These point to obligations to check; they aren't legal or tax advice.