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.

Tool bills at 1,000 usersAbout $40/month
Setup steps10
Tools in the stack10

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

CategoryPickWhyPick something else when
HostingVercelNative Next.js hosting with preview deployments. Use Pro once you charge customers; Hobby is non-commercial.
  • Render: You need long-running workers or websockets in the same app.
  • Cloudflare Workers: Cost at very high request volume matters more than native Next.js.
DatabaseNeonPlain Postgres that scales to zero, with a database branch for each preview deployment.
  • Supabase: You want database, auth and storage from one vendor.
  • Turso: You want a separate database per customer.
AuthWorkOS AuthKitOrganizations are the core model, 1M monthly users are free, and enterprise SSO is ready when a large customer asks.
  • Clerk: You want prebuilt sign-in UI fastest and don't expect enterprise SSO soon.
  • Better Auth: You want users in your own database with no per-user fees, and will run it yourself.
PaymentsStripeSubscriptions, seats, invoices and a hosted customer portal at the lowest base fee.
  • Paddle: You sell worldwide and want a merchant of record to file sales tax and VAT.
EmailResendSimple API with React Email templates and 3,000 free emails a month.
  • Postmark: Transactional deliverability and long retention matter most.
SecretsoptionalInfisicalOne source of truth for env vars across dev, preview and production, synced to Vercel.
  • Doppler: You prefer a hosted service with more integrations.
DNSCloudflare DNSFree DNS with a good API; holds the app and email records.
MonitoringSentryError tracking with source maps from the official Next.js SDK.
  • Better Stack: You also want uptime checks and a public status page.
AnalyticsoptionalPostHogGroup analytics reports usage per customer account, so you can see which companies use which features.
  • Plausible: You only need cookieless traffic stats for the marketing site.
StorageoptionalCloudflare R2S3-compatible uploads and exports with no egress fees.
  • Vercel Blob: Uploads are small and you want zero setup on Vercel.

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

Setup steps

  1. 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 zod
    src/env.ts
    import { 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.

  2. 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.sql
    create 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

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

    Done when: Two users in one organization see the same data; a user in another organization sees none of it.

    Docs: workos.com, workos.com

  4. 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/webhook
    src/app/api/stripe/webhook/route.ts
    import 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.

    Docs: docs.stripe.com, docs.stripe.com, docs.stripe.com

  5. 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/components

    Done when: An invite email lands in the inbox (not spam) for Gmail and Outlook test accounts.

    Docs: resend.com, resend.com

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

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

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

    Done when: A thrown test error appears in Sentry with a readable stack trace and the organization tag.

    Docs: docs.sentry.io, posthog.com

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

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

These point to obligations to check; they aren't legal or tax advice.