SaaS-Boilerplate mit Claude Code bauen: Next.js Auth, Billing, Tenants und Tests
Baue mit Claude Code ein SaaS-Starterkit: Next.js, Auth, Billing, Tenants, Audit-Logs, Tests und Launch-Checks.
Ein SaaS-Boilerplate ist die wiederverwendbare Basis für ein bezahltes Webprodukt: Authentifizierung, Billing, Tenants, Rollen, E-Mail, Dashboard, Admin-Bereich, Audit-Logs, Umgebungsvariablen, Tests, Dokumentation und Deploy-Checkliste. Claude Code kann diese Basis schnell erzeugen, aber ein Starterkit für echte Kunden braucht klarere Grenzen als eine Demo.
Der gefährliche Kurzschluss lautet: “Die App läuft, also ist sie fertig.” Ein Tenant ist ein Unternehmen, Workspace oder Kundenkonto innerhalb derselben Anwendung. Wenn Tenant-Prüfungen schwach sind, kann ein Kunde Daten eines anderen Kunden sehen. RBAC bedeutet rollenbasierte Zugriffskontrolle: Owner, Admin, Billing, Member und Viewer dürfen nicht dieselben Rechte haben. Ein Audit-Log hält fest, wer wann was getan hat. Ohne diese Spur werden Support, Incident Response und Enterprise-Vertrieb unnötig schwierig.
Dieser Leitfaden zeigt, wie du Claude Code anleitest und ein Next.js App Router, TypeScript, Prisma, Stripe und Resend Starterkit aufbaust, das als bezahltes Produkt oder Template funktionieren kann. Prüfe Details immer gegen die offiziellen Quellen: Claude Code docs, Next.js docs, Auth.js, Prisma schema docs, Stripe webhooks, Resend docs und OWASP Authentication Cheat Sheet.
Passende ClaudeCodeLab-Artikel sind sichere Authentifizierung, RBAC-Implementierung, Zod-Validierung und API-Entwicklung mit Claude Code.
Vom bezahlten Produkt her denken
Die erste Frage ist nicht, welche Komponenten Claude Code erzeugen soll. Die erste Frage ist, was ein Käufer mit diesem Starter tatsächlich veröffentlichen kann. Ein Template mit vielen Screens, aber unscharfen Tenant-Grenzen, ist weniger wert als ein kleineres Template mit Tests, klarer Dokumentation und Review-Regeln.
| Use Case | Notwendige Basis | Monetarisierung |
|---|---|---|
| Solo-Micro-SaaS | OAuth-Login, persönlicher Plan, Stripe Checkout, Usage-Dashboard | Einfacher monatlicher Plan |
| B2B-Teamtool | Tenants, Einladungen, Rollen, Billing-Verantwortliche, Audit-Logs | Preis pro Seat oder Workspace |
| Mitglieder- oder Template-Portal | Käuferrollen, Downloadhistorie, E-Mail-Benachrichtigung, Admin | Bezahlte Template-Packs und Kurse |
| Internes KI-Workflow-Tool | SSO, Freigaben, IP- oder Domain-Regeln, Operations-Logs | Implementierungsberatung |
Behaupte nicht, dass ein Boilerplate Legal-, Steuer-, Privacy- oder Security-Review ersetzt. Stripe-Steuern, Rückerstattungen, AGB, Datenschutzerklärung, Datenaufbewahrung, Support und Berechtigungsprüfung brauchen weiterhin menschliche Verantwortung. Claude Code beschleunigt Implementierung, aber übernimmt nicht die Produkthaftung.
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"]
Claude Code mit CLAUDE.md führen
Wenn du nur “baue eine SaaS-App” schreibst, mischt Claude Code leicht UI, API-Routen, Datenmodelle und Business-Regeln in einem großen Diff. CLAUDE.md macht daraus einen Vertrag. Einfach gesagt ist es das Harness: das Arbeitsgerüst, das Änderungen des Agents reviewbar hält.
# 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.
Diese Regeln verhindern typische Abkürzungen: kein Vertrauen in tenantId aus dem Browser, keine Secrets im Code, kein Billing-Zugriff für normale Member und keine Zustandsänderung ohne Audit-Spur.
Nach Verantwortlichkeiten strukturieren
Eine kleine Demo kann alles in einem Routenordner halten. Ein SaaS kann das nicht. Auth, Billing, Teamverwaltung, E-Mail und Audit greifen schnell ineinander, also muss die Struktur die Grenzen sichtbar machen.
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
Damit werden auch Prompts präziser. Du kannst “ändere nur den Billing-Webhook und die zugehörigen Tests” verlangen, ohne dass der Agent die ganze Anwendung anfasst.
Prisma-Schema mit Tenant-Grenze
Das folgende Schema ist ein praktisches Minimum. Tenant steht für Unternehmen, Workspace oder Kundenkonto. Membership verbindet User, Tenant und Rolle. Subscription speichert den Stripe-Status. AuditLog hält wichtige Änderungen fest.
// 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])
}
Echte Produkte ergänzen oft Rechnungen, Adressen, Usage-Counter, API-Keys, Datenaufbewahrung und Support-Notizen. Du musst nicht alles am ersten Tag modellieren. Wichtig ist, dass die Tenant-Grenze nicht übersehen werden kann.
Umgebungsvariablen einmal validieren
Secrets gehören nicht in Code, Screenshots oder Commits. .env.example enthält nur Namen, reale Werte liegen in Vercel, Cloudflare, AWS, GitHub Actions oder einer anderen Secret-Verwaltung. Ein kleines env.ts findet fehlende Schlüssel früh.
// 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);
Tenant und Rolle serverseitig prüfen
Der Browser kann tenantId senden, aber der Server darf diesem Wert nicht vertrauen. Vor jedem Lesen oder Schreiben von Business-Daten muss die Membership serverseitig geladen werden.
// 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,
};
}
Ein häufiger Fehler ist, tenantId in ein Hidden Input zu legen und direkt für Updates zu nutzen. Hidden Inputs sind nicht geheim. Jede Schreiboperation braucht eine neue Membership-Prüfung auf dem Server.
Stripe-Webhooks als wiederholbare Events behandeln
Ein Webhook ist eine externe Benachrichtigung. Er kann mehrmals oder verspätet eintreffen. Ein produktnahes Starterkit prüft die Signatur, verlangt metadata.tenantId, nutzt upsert und schreibt ein 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 });
}
E-Mail und Audit zentralisieren
Einladungen, fehlgeschlagene Zahlungen, Passwort-Reset und Admin-Alerts brauchen E-Mails. Billing-Änderungen, Rollenänderungen und Tenant-Einstellungen brauchen Audit-Logs. Gemeinsame Helper machen das Produkt leichter reviewbar.
// 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,
},
});
}
Acceptance-Tests vor UI-Politur festlegen
Ein Acceptance-Test prüft, ob eine Funktion aus Nutzersicht akzeptabel ist. Bei einem bezahlten SaaS-Starter sind Ablehnungspfade oft wichtiger als der Happy Path.
// 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();
});
Der typische Fehler ist, nur Login, Workspace-Erstellung, Zahlung und Dashboard zu testen. Teste auch, dass ein anderer Tenant blockiert wird, ein Member Billing nicht ändern kann und ein doppelter Webhook den Subscription-Status nicht beschädigt.
Launch-Checkliste
Vor Veröffentlichung oder Verkauf des Starters sollten mindestens diese Punkte geprüft sein.
- Metadata, Hero-Image, offizielle Links, interne Links und CTA sind vorhanden
.env.exampleenthält nur Namen und keine echten Secrets- Jede Business-Änderung prüft serverseitig die Tenant-Membership
- OWNER, ADMIN, BILLING, MEMBER und VIEWER haben getrennte Tests
- Stripe Webhook prüft Signatur und Idempotenz
- E-Mail-Fehler haben Retry oder manuellen Resend
- Admin-Bereich und Audit-Logs funktionieren in Produktion
- Terms, Privacy, Refunds, Steuern und Datenaufbewahrung wurden menschlich geprüft
- README dokumentiert lokales Setup, Seed, Tests und Deployment
Als Produkt paketieren
Wenn du ein SaaS-Boilerplate verkaufen willst, ist Code nur ein Teil des Werts. Käufer brauchen Setup-Dokumentation, Env-Map, Stripe-Konfiguration, Seed-Daten, Acceptance-Tests und klare Support-Grenzen.
| Paket | Inhalt | Geeignet für |
|---|---|---|
| Free checklist | CLAUDE.md-Beispiel, Env-Map, Release-Checkliste | Solo-Builders beim Validieren |
| Starter template | Next.js, Prisma, Auth.js, Stripe, Resend, Tests | MVP an einem Wochenende |
| Pro template | Admin, Audit-Logs, Usage Billing, Einladungen, Docs | Ernsthafter bezahlter SaaS-Launch |
| Team rollout | Repo-Review, Claude-Code-Training, Review-Regeln | Unternehmen, die Claude Code einführen |
ClaudeCodeLab bietet ein kostenloses Cheatsheet, Claude Code Produkte und Templates sowie Training oder Implementierungsberatung. Nutze das kostenlose Material zum Validieren und wechsle zu Templates oder Beratung, wenn der Starter in ein echtes Repository passen muss.
Ergebnis aus der Praxis
In einem kleinen Validierungs-Repository startete Masa mit einem zu breiten Prompt: “baue das Dashboard”. Der Screen funktionierte, aber der direkte Zugriff auf eine andere Tenant-URL wurde nicht blockiert und der Stripe-Webhook prüfte die Signatur nicht. Nachdem Tenant-Grenzen, Rollen, Audit-Logs und Acceptance-Tests in CLAUDE.md standen, erzeugte Claude Code kleinere Diffs und der Review hatte eine klare Liste. Die Lehre ist praktisch: Der Wert eines SaaS-Boilerplates liegt in Grenzen, Tests und Betriebsdokumentation, nicht in der längsten Feature-Liste.
Kostenloses PDF: Claude-Code-Cheatsheet
E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.
Wir schützen Ihre Daten und senden keinen Spam.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.
Ähnliche Artikel
Claude Code Workflow von Obsidian zu CLAUDE.md
Obsidian-Arbeitsnotizen in CLAUDE.md-Betriebsnotizen verwandeln und Kontext nicht ständig neu erklären.
Claude Code Revenue CTA Routing: Artikel zu PDF, Gumroad und Beratung führen
Ein Claude-Code-Ablauf, der Leser nach Absicht zu Gratis-PDF, Gumroad oder Beratung führt.
Claude-Code-Team-Handoff-Regeln: Belege, Berechtigungen, Rollback und Umsatzpfade
Ein praktisches Claude-Code-Handoff für Review-Belege, Berechtigungen, Rollback, Gratis-PDF, Gumroad und Beratung.