Use Cases (Aktualisiert: 2.6.2026)

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.

SaaS-Boilerplate mit Claude Code bauen: Next.js Auth, Billing, Tenants und Tests

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 CaseNotwendige BasisMonetarisierung
Solo-Micro-SaaSOAuth-Login, persönlicher Plan, Stripe Checkout, Usage-DashboardEinfacher monatlicher Plan
B2B-TeamtoolTenants, Einladungen, Rollen, Billing-Verantwortliche, Audit-LogsPreis pro Seat oder Workspace
Mitglieder- oder Template-PortalKäuferrollen, Downloadhistorie, E-Mail-Benachrichtigung, AdminBezahlte Template-Packs und Kurse
Internes KI-Workflow-ToolSSO, Freigaben, IP- oder Domain-Regeln, Operations-LogsImplementierungsberatung

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.example enthä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.

PaketInhaltGeeignet für
Free checklistCLAUDE.md-Beispiel, Env-Map, Release-ChecklisteSolo-Builders beim Validieren
Starter templateNext.js, Prisma, Auth.js, Stripe, Resend, TestsMVP an einem Wochenende
Pro templateAdmin, Audit-Logs, Usage Billing, Einladungen, DocsErnsthafter bezahlter SaaS-Launch
Team rolloutRepo-Review, Claude-Code-Training, Review-RegelnUnternehmen, 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.

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

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.

Masa

Über den Autor

Masa

Engineer für praktische Claude-Code-Workflows und Team-Einführung.