Use Cases (Updated: 6/2/2026)

Build a SaaS Boilerplate with Claude Code: Next.js Auth, Billing, Tenants, and Tests

Use Claude Code to build a paid SaaS starter with Next.js, auth, billing, tenants, audit logs, tests, and launch checks.

Build a SaaS Boilerplate with Claude Code: Next.js Auth, Billing, Tenants, and Tests

A SaaS boilerplate is the reusable foundation behind a paid web product: authentication, billing, tenants, roles, email, dashboards, admin screens, audit logs, environment variables, tests, documentation, and a deployment checklist. Claude Code can generate that foundation quickly, but a product-ready starter needs stronger boundaries than a demo.

The risky shortcut is to treat “the app runs” as the finish line. A tenant means one company, workspace, or customer account inside the same application. If tenant checks are weak, one customer can see another customer’s data. RBAC means role-based access control: owners, admins, billing users, members, and viewers should not share the same powers. An audit log records who did what and when. Without it, support, incident response, and enterprise sales become much harder.

This guide shows how to prompt Claude Code and shape a Next.js App Router, TypeScript, Prisma, Stripe, and Resend SaaS starter that can become a paid product or productized template. Keep official references open while implementing: Claude Code docs, Next.js docs, Auth.js, Prisma schema docs, Stripe webhooks, Resend docs, and the OWASP Authentication Cheat Sheet.

For deeper ClaudeCodeLab reading, pair this with secure authentication, RBAC implementation, Zod validation, and API development with Claude Code.

Start from the paid product shape

The first question is not “which components should Claude Code generate?” The first question is “what will a buyer or paying customer be able to launch with this starter?” A boilerplate that has ten screens but weak tenant boundaries is less valuable than a smaller starter with a clear review path, tests, and deployment notes.

Here are four concrete use cases worth designing for.

Use caseRequired foundationMonetization path
Solo micro-SaaSOAuth login, personal plan, Stripe Checkout, usage dashboardLow-friction monthly plan
B2B team toolTenants, invites, roles, billing owner, audit logsPer-seat or per-workspace pricing
Member content or template portalPurchase roles, download history, email notifications, admin screenPaid template packs and courses
Internal AI workflow toolSSO, approvals, IP or domain policy, operation logsImplementation consulting and support

Do not claim a boilerplate removes legal, tax, privacy, or security review. Stripe tax settings, refunds, terms of service, privacy policy, data retention, customer support, and permission audits still need human review. Claude Code speeds up implementation; it does not transfer business responsibility away from the team.

flowchart LR
  A["Marketing site"] --> B["Auth"]
  B --> C["Tenant and roles"]
  C --> D["Dashboard"]
  C --> E["Billing"]
  C --> F["Admin"]
  D --> G["Audit logs"]
  E --> G
  F --> G
  G --> H["Tests and release checklist"]

Give Claude Code a CLAUDE.md contract

If you ask Claude Code to “build a SaaS app,” the result often mixes UI, API routes, database models, and business rules in one large diff. A CLAUDE.md file turns the task into a contract. In plain language, it is the harness: the working surface that keeps the agent’s changes reviewable.

# CLAUDE.md

## Product goal
Build a paid SaaS starter that can be reused for real products.
Do not claim the starter removes legal, tax, privacy, or security review.

## Stack
- Next.js App Router with TypeScript
- Prisma and PostgreSQL
- Auth.js for OAuth/session integration
- Stripe Checkout and billing webhooks
- Resend for transactional email
- Vitest and Playwright for acceptance tests

## Required boundaries
- Every business record belongs to a tenantId.
- Never trust tenantId from the browser without checking membership.
- Roles are OWNER, ADMIN, BILLING, MEMBER, VIEWER.
- Billing routes require OWNER or BILLING.
- Admin routes require OWNER or ADMIN.
- State-changing routes write an audit log.
- Secrets must be read through src/lib/env.ts and never hardcoded.
- Include tests for forbidden tenant access and webhook idempotency.

## Review output
After edits, list changed files, commands run, risks, and manual checks.

This matters most for beginners. The rules tell Claude Code which shortcuts are not allowed: no trusting browser-provided tenant IDs, no hardcoded secrets, no billing route without a role guard, and no state-changing route without an audit trail.

Organize the starter by responsibility

Small demos can keep everything in one route folder. SaaS products cannot. Authentication, billing, team management, email, and audit logging overlap quickly, so the folder structure should make boundaries visible.

src/
  app/
    (marketing)/
    (auth)/
    (dashboard)/dashboard/page.tsx
    (admin)/admin/page.tsx
    api/
      billing/checkout/route.ts
      billing/webhook/route.ts
      tenants/invite/route.ts
  components/
    dashboard/
    pricing/
    ui/
  lib/
    auth.ts
    env.ts
    prisma.ts
    tenant.ts
    audit.ts
    email.ts
    stripe.ts
  tests/
    acceptance/saas.spec.ts
prisma/
  schema.prisma

This structure also helps with Claude Code prompts. You can ask for “only update billing webhook and related tests” instead of letting the agent touch every part of the app.

Use a Prisma schema that protects tenants

The following schema is a practical minimum. Tenant represents a company, workspace, or customer account. Membership connects a user to a tenant with a role. Subscription stores the Stripe state. AuditLog records important state changes.

// prisma/schema.prisma
enum Role {
  OWNER
  ADMIN
  BILLING
  MEMBER
  VIEWER
}

enum Plan {
  FREE
  STARTER
  PRO
}

enum SubscriptionStatus {
  TRIALING
  ACTIVE
  PAST_DUE
  CANCELED
}

model User {
  id          String       @id @default(cuid())
  email       String       @unique
  name        String?
  memberships Membership[]
  auditLogs   AuditLog[]
  createdAt   DateTime     @default(now())
}

model Tenant {
  id             String        @id @default(cuid())
  name           String
  slug           String        @unique
  plan           Plan          @default(FREE)
  memberships    Membership[]
  subscription   Subscription?
  auditLogs      AuditLog[]
  createdAt      DateTime      @default(now())
  updatedAt      DateTime      @updatedAt
}

model Membership {
  id        String   @id @default(cuid())
  userId    String
  tenantId  String
  role      Role     @default(MEMBER)
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  tenant    Tenant   @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  createdAt DateTime @default(now())

  @@unique([userId, tenantId])
  @@index([tenantId, role])
}

model Subscription {
  id                   String             @id @default(cuid())
  tenantId             String             @unique
  stripeCustomerId     String             @unique
  stripeSubscriptionId String?            @unique
  status               SubscriptionStatus @default(TRIALING)
  priceId              String?
  currentPeriodEnd     DateTime?
  tenant               Tenant             @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  updatedAt            DateTime           @updatedAt
}

model AuditLog {
  id        String   @id @default(cuid())
  tenantId  String
  actorId   String?
  action    String
  metadata  Json?
  tenant    Tenant   @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  actor     User?    @relation(fields: [actorId], references: [id])
  createdAt DateTime @default(now())

  @@index([tenantId, createdAt])
}

Real products usually add invoices, addresses, usage counters, API keys, data retention, and customer support notes. The point is not to model everything on day one. The point is to make the tenant boundary impossible to ignore.

Validate environment variables once

Secrets should never live in code, screenshots, or committed notes. Put only placeholder names in .env.example, then store real values in Vercel, Cloudflare, AWS, GitHub Actions, or your chosen platform. A small env.ts module catches missing keys before a webhook silently fails.

// src/lib/env.ts
import { z } from "zod";

export const env = z
  .object({
    DATABASE_URL: z.string().url(),
    AUTH_SECRET: z.string().min(32),
    NEXT_PUBLIC_APP_URL: z.string().url(),
    STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
    STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"),
    STRIPE_PRICE_STARTER: z.string().min(1),
    RESEND_API_KEY: z.string().startsWith("re_"),
    EMAIL_FROM: z.string().email(),
  })
  .parse(process.env);

Check tenant and role on the server

The browser can send a tenantId, but the browser cannot be trusted. Always load the membership on the server before reading or mutating business data.

// src/lib/tenant.ts
import { Role } from "@prisma/client";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";

const roleRank: Record<Role, number> = {
  VIEWER: 1,
  MEMBER: 2,
  BILLING: 3,
  ADMIN: 4,
  OWNER: 5,
};

export async function requireTenant(tenantId: string, minimumRole: Role = "MEMBER") {
  const session = await auth();
  if (!session?.user?.id) redirect("/login");

  const membership = await prisma.membership.findUnique({
    where: {
      userId_tenantId: {
        userId: session.user.id,
        tenantId,
      },
    },
    include: { tenant: true },
  });

  if (!membership || roleRank[membership.role] < roleRank[minimumRole]) {
    throw new Error("Forbidden tenant access");
  }

  return {
    userId: session.user.id,
    tenant: membership.tenant,
    role: membership.role,
  };
}

A common failure mode is to put tenantId in a hidden form input and trust it during updates. Hidden inputs are not secret. The server must verify membership every time.

Treat Stripe webhooks as repeated events

A webhook is an external notification. It can arrive more than once, and events can be delayed. A production starter should verify the signature, require metadata.tenantId, use upsert, and write an audit log.

// src/app/api/billing/webhook/route.ts
import Stripe from "stripe";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { SubscriptionStatus } from "@prisma/client";
import { env } from "@/lib/env";
import { prisma } from "@/lib/prisma";

const stripe = new Stripe(env.STRIPE_SECRET_KEY);

function toStatus(status: Stripe.Subscription.Status): SubscriptionStatus {
  if (status === "active") return "ACTIVE";
  if (status === "past_due") return "PAST_DUE";
  if (status === "canceled") return "CANCELED";
  return "TRIALING";
}

export async function POST(request: Request) {
  const body = await request.text();
  const signature = (await headers()).get("stripe-signature");

  if (!signature) {
    return NextResponse.json({ error: "Missing Stripe signature" }, { status: 400 });
  }

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, env.STRIPE_WEBHOOK_SECRET);
  } catch {
    return NextResponse.json({ error: "Invalid Stripe signature" }, { status: 400 });
  }

  if (
    event.type === "customer.subscription.created" ||
    event.type === "customer.subscription.updated" ||
    event.type === "customer.subscription.deleted"
  ) {
    const subscription = event.data.object as Stripe.Subscription;
    const tenantId = subscription.metadata.tenantId;

    if (!tenantId || typeof subscription.customer !== "string") {
      return NextResponse.json({ error: "Missing tenant metadata" }, { status: 400 });
    }

    await prisma.subscription.upsert({
      where: { tenantId },
      create: {
        tenantId,
        stripeCustomerId: subscription.customer,
        stripeSubscriptionId: subscription.id,
        status: toStatus(subscription.status),
        priceId: subscription.items.data[0]?.price.id,
        currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      },
      update: {
        stripeSubscriptionId: subscription.id,
        status: toStatus(subscription.status),
        priceId: subscription.items.data[0]?.price.id,
        currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      },
    });

    await prisma.auditLog.create({
      data: {
        tenantId,
        action: `stripe.${event.type}`,
        metadata: { eventId: event.id, subscriptionId: subscription.id },
      },
    });
  }

  return NextResponse.json({ received: true });
}

Centralize email and audit logs

Invites, failed payments, password resets, and admin alerts all need email. Billing changes, role changes, and tenant settings changes all need logs. Keep those helpers shared so the product can be reviewed.

// src/lib/email.ts
import { Resend } from "resend";
import { env } from "@/lib/env";

const resend = new Resend(env.RESEND_API_KEY);

export async function sendTenantInviteEmail(input: {
  to: string;
  tenantName: string;
  inviteUrl: string;
}) {
  return resend.emails.send({
    from: env.EMAIL_FROM,
    to: input.to,
    subject: `${input.tenantName} invited you`,
    html: `<p>You were invited to ${input.tenantName}.</p><p><a href="${input.inviteUrl}">Accept invite</a></p>`,
  });
}
// src/lib/audit.ts
import { prisma } from "@/lib/prisma";

export async function writeAuditLog(input: {
  tenantId: string;
  actorId?: string;
  action: string;
  metadata?: Record<string, unknown>;
}) {
  return prisma.auditLog.create({
    data: {
      tenantId: input.tenantId,
      actorId: input.actorId,
      action: input.action,
      metadata: input.metadata,
    },
  });
}

Define acceptance tests before polishing UI

An acceptance test asks whether the feature is acceptable from the user’s point of view. For a paid SaaS starter, the most important tests are often refusal paths.

// tests/acceptance/saas.spec.ts
import { test, expect } from "@playwright/test";

test("member cannot open billing settings", async ({ page }) => {
  await page.goto("/test-login?role=MEMBER");
  await page.goto("/dashboard/acme/billing");
  await expect(page.getByText("Forbidden")).toBeVisible();
});

test("billing user can open billing settings", async ({ page }) => {
  await page.goto("/test-login?role=BILLING");
  await page.goto("/dashboard/acme/billing");
  await expect(page.getByRole("heading", { name: "Billing" })).toBeVisible();
});

test("tenant switch does not leak another tenant data", async ({ page }) => {
  await page.goto("/test-login?tenant=acme");
  await page.goto("/dashboard/other-team/settings");
  await expect(page.getByText("Forbidden")).toBeVisible();
});

The common mistake is testing only the happy path: user signs in, creates a workspace, pays, and sees a dashboard. Also test that another tenant cannot be opened, a member cannot change billing, and a repeated webhook does not corrupt subscription state.

Launch checklist

Before publishing or selling the starter, review these items.

  • Metadata, hero image, official links, internal links, and CTA are present
  • .env.example lists names only and contains no real secrets
  • Every business update checks tenant membership server-side
  • OWNER, ADMIN, BILLING, MEMBER, and VIEWER differences are tested
  • Stripe webhook signature verification and idempotency are covered
  • Email failures have a retry or manual resend path
  • Admin screen and audit logs work in production
  • Terms, privacy policy, refund policy, tax settings, and data retention are reviewed by humans
  • README documents local setup, seed data, tests, and deployment

Package it as a product

If you want to sell a SaaS boilerplate, code is only part of the value. Buyers also need setup docs, environment variable maps, Stripe configuration screenshots, seed data, acceptance tests, and a clear support boundary.

PackageContentsBest fit
Free checklistCLAUDE.md sample, env map, release checklistSolo builders evaluating the idea
Starter templateNext.js, Prisma, Auth.js, Stripe, Resend, testsPeople building an MVP in a weekend
Pro templateAdmin, audit logs, usage billing, invites, docsBuilders serious about a paid SaaS
Team rolloutRepo review, Claude Code training, review rulesCompanies adopting Claude Code

ClaudeCodeLab offers a free cheatsheet, Claude Code products and templates, and training or implementation consultation. Use the free material to validate the workflow, then move to templates or consulting when the starter has to fit a real repository.

What happened in practice

In Masa’s small validation repo, the first prompt was too vague: “build the dashboard.” It produced a working screen, but direct navigation to another tenant URL was not blocked and the Stripe webhook path missed signature verification. After moving tenant boundaries, roles, audit logs, and acceptance tests into CLAUDE.md, Claude Code produced smaller diffs and the review had a clear checklist. The lesson is practical: a SaaS boilerplate is valuable because of its boundaries, tests, and operating docs, not because it has the longest feature list.

#Claude Code #SaaS #boilerplate #Next.js #Prisma #Stripe
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.