Use Cases (Diperbarui: 2/6/2026)

Membuat SaaS boilerplate dengan Claude Code: Next.js auth, billing, tenant, dan test

Bangun starter SaaS berbayar dengan Claude Code: Next.js, auth, billing, tenant, audit log, test, dan checklist.

Membuat SaaS boilerplate dengan Claude Code: Next.js auth, billing, tenant, dan test

SaaS boilerplate adalah fondasi reusable untuk produk web berbayar: autentikasi, billing, tenant, role, email, dashboard, admin, audit log, environment variable, test, dokumentasi, dan checklist deploy. Claude Code bisa membuat fondasi ini dengan cepat, tetapi starter yang siap dijual perlu batasan yang lebih kuat daripada sekadar demo.

Shortcut yang berbahaya adalah menganggap “aplikasi sudah jalan” sebagai selesai. Tenant berarti perusahaan, workspace, atau akun pelanggan di dalam aplikasi yang sama. Jika pengecekan tenant lemah, satu pelanggan bisa melihat data pelanggan lain. RBAC berarti role-based access control: owner, admin, billing, member, dan viewer tidak boleh punya izin yang sama. Audit log mencatat siapa melakukan apa dan kapan. Tanpa itu, support, incident response, dan penjualan enterprise menjadi jauh lebih sulit.

Panduan ini menunjukkan cara memberi instruksi ke Claude Code dan menyusun starter Next.js App Router, TypeScript, Prisma, Stripe, dan Resend yang bisa menjadi produk berbayar atau template komersial. Selalu cek implementasi terhadap dokumentasi resmi: Claude Code docs, Next.js docs, Auth.js, Prisma schema docs, Stripe webhooks, Resend docs, dan OWASP Authentication Cheat Sheet.

Bacaan lanjutan di ClaudeCodeLab: autentikasi aman, implementasi RBAC, validasi Zod, dan pengembangan API dengan Claude Code.

Mulai dari bentuk produk berbayar

Pertanyaan pertama bukan “komponen apa yang akan dibuat Claude Code”. Pertanyaan pertama adalah “apa yang bisa diluncurkan pembeli starter ini”. Template dengan banyak layar tetapi batas tenant lemah lebih rendah nilainya dibanding template yang lebih kecil tetapi punya test, dokumentasi, dan aturan review yang jelas.

Use caseFondasi yang dibutuhkanJalur monetisasi
Micro-SaaS soloOAuth login, personal plan, Stripe Checkout, usage dashboardPaket bulanan yang mudah dicoba
Tool B2B untuk timTenant, invite, role, billing owner, audit logHarga per seat atau workspace
Portal konten atau templateRole pembeli, riwayat download, notifikasi email, adminPaket template dan kursus berbayar
Tool AI internalSSO, approval, aturan IP atau domain, operation logKonsultasi implementasi

Jangan klaim bahwa boilerplate menghapus kebutuhan legal, tax, privacy, atau security review. Pengaturan pajak Stripe, refund, terms of service, privacy policy, data retention, customer support, dan review permission tetap perlu manusia. Claude Code mempercepat implementasi, bukan mengambil alih tanggung jawab bisnis.

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"]

Beri Claude Code kontrak CLAUDE.md

Jika Anda hanya meminta “buat aplikasi SaaS”, Claude Code bisa mencampur UI, API route, model database, dan aturan bisnis dalam satu diff besar. CLAUDE.md mengubah tugas menjadi kontrak. Secara sederhana, ini adalah harness: landasan kerja yang membuat perubahan agent tetap mudah direview.

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

Aturan ini mencegah shortcut umum: mempercayai tenantId dari browser, menyimpan secret di kode, memberi akses billing ke member, atau mengubah state tanpa audit log.

Susun starter berdasarkan tanggung jawab

Demo kecil bisa menyimpan semuanya dalam satu folder route. SaaS tidak. Auth, billing, team management, email, dan audit cepat saling terkait, jadi struktur folder harus membuat batasnya terlihat.

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

Struktur ini juga membuat prompt lebih aman. Anda bisa meminta “ubah hanya billing webhook dan test terkait” tanpa membiarkan agent menyentuh seluruh aplikasi.

Gunakan schema Prisma yang menjaga tenant

Schema berikut adalah minimum yang praktis. Tenant mewakili perusahaan, workspace, atau akun pelanggan. Membership menghubungkan user dengan tenant dan role. Subscription menyimpan status Stripe. AuditLog mencatat perubahan penting.

// 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])
}

Produk nyata biasanya menambah invoice, address, usage counter, API key, data retention, dan catatan support. Tidak harus memodelkan semuanya di hari pertama. Yang penting, batas tenant tidak bisa diabaikan.

Validasi environment variable sekali

Secret tidak boleh ada di kode, screenshot, atau commit. .env.example hanya berisi nama; nilai asli disimpan di Vercel, Cloudflare, AWS, GitHub Actions, atau platform lain. env.ts membuat aplikasi gagal lebih awal jika key penting belum ada.

// 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);

Cek tenant dan role di server

Browser boleh mengirim tenantId, tetapi server tidak boleh mempercayainya. Sebelum membaca atau mengubah data bisnis, load membership di server.

// 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,
  };
}

Kesalahan umum adalah memasukkan tenantId ke hidden input lalu langsung memakainya untuk update. Hidden input bukan rahasia. Setiap operasi tulis harus mengecek membership di server.

Anggap Stripe Webhook bisa berulang

Webhook adalah notifikasi eksternal. Event yang sama bisa datang lebih dari sekali atau terlambat. Starter yang siap produksi harus memverifikasi signature, mewajibkan metadata.tenantId, memakai upsert, dan menulis 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 });
}

Sentralisasi email dan audit

Invite, pembayaran gagal, reset password, dan alert admin memakai email. Perubahan billing, role, dan setting tenant membutuhkan audit log. Helper bersama membuat produk lebih mudah direview.

// 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,
    },
  });
}

Tetapkan acceptance test sebelum memoles UI

Acceptance test mengecek apakah fitur bisa diterima dari sudut pandang pengguna. Untuk SaaS berbayar, jalur penolakan sering sama pentingnya dengan 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();
});

Kesalahan umum adalah hanya mengetes user bisa login, membuat workspace, membayar, dan melihat dashboard. Test juga bahwa tenant lain diblokir, member tidak bisa mengubah billing, dan webhook berulang tidak merusak status subscription.

Checklist launch

Sebelum menerbitkan atau menjual starter, cek setidaknya poin berikut.

  • Metadata, hero image, link resmi, internal link, dan CTA ada
  • .env.example berisi nama saja, bukan secret asli
  • Semua update bisnis mengecek tenant membership di server
  • OWNER, ADMIN, BILLING, MEMBER, dan VIEWER punya test terpisah
  • Stripe Webhook punya signature verification dan idempotency
  • Kegagalan email punya retry atau manual resend
  • Admin screen dan audit log berjalan di production
  • Terms, privacy policy, refund policy, tax settings, dan data retention direview manusia
  • README menjelaskan local setup, seed, test, dan deploy

Kemas sebagai produk

Jika ingin menjual SaaS boilerplate, kode hanya satu bagian dari value. Pembeli juga butuh setup docs, peta environment variable, konfigurasi Stripe, seed data, acceptance tests, dan batas support yang jelas.

PaketIsiCocok untuk
Free checklistContoh CLAUDE.md, env map, release checklistSolo builder yang validasi ide
Starter templateNext.js, Prisma, Auth.js, Stripe, Resend, testsMembuat MVP dalam akhir pekan
Pro templateAdmin, audit logs, usage billing, invites, docsSerius meluncurkan SaaS berbayar
Team rolloutReview repo, training Claude Code, aturan reviewPerusahaan yang mengadopsi Claude Code

ClaudeCodeLab menyediakan cheatsheet gratis, produk dan template Claude Code, serta training atau konsultasi implementasi. Gunakan materi gratis untuk memvalidasi workflow, lalu pakai template atau konsultasi ketika starter harus cocok dengan repository nyata.

Hasil praktik

Di repository validasi kecil, Masa pertama kali memberi prompt yang terlalu luas: “buat dashboard”. Layarnya berjalan, tetapi akses langsung ke URL tenant lain tidak diblokir dan endpoint Stripe Webhook tidak memverifikasi signature. Setelah tenant boundary, role, audit log, dan acceptance test dipindahkan ke CLAUDE.md, Claude Code menghasilkan diff yang lebih kecil dan review punya checklist jelas. Pelajarannya praktis: value SaaS boilerplate ada pada boundary, test, dan dokumentasi operasional, bukan pada daftar fitur terpanjang.

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

PDF gratis: cheatsheet Claude Code

Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.

Kami menjaga datamu dan tidak mengirim spam.

Masa

Tentang penulis

Masa

Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.