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.
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 case | Required foundation | Monetization path |
|---|---|---|
| Solo micro-SaaS | OAuth login, personal plan, Stripe Checkout, usage dashboard | Low-friction monthly plan |
| B2B team tool | Tenants, invites, roles, billing owner, audit logs | Per-seat or per-workspace pricing |
| Member content or template portal | Purchase roles, download history, email notifications, admin screen | Paid template packs and courses |
| Internal AI workflow tool | SSO, approvals, IP or domain policy, operation logs | Implementation 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.examplelists 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.
| Package | Contents | Best fit |
|---|---|---|
| Free checklist | CLAUDE.md sample, env map, release checklist | Solo builders evaluating the idea |
| Starter template | Next.js, Prisma, Auth.js, Stripe, Resend, tests | People building an MVP in a weekend |
| Pro template | Admin, audit logs, usage billing, invites, docs | Builders serious about a paid SaaS |
| Team rollout | Repo review, Claude Code training, review rules | Companies 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.
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.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.