Use Cases (更新: 2026/6/2)

Claude CodeでSaaSボイラープレートを作る実践ガイド|Next.js・課金・認証

Claude Codeで有料SaaSの土台を作る実践ガイド。認証、課金、テナント、監査、テストまでNext.jsで解説。

Claude CodeでSaaSボイラープレートを作る実践ガイド|Next.js・課金・認証

SaaSボイラープレートとは、新しいWebサービスを作るたびに必要になる認証、課金、テナント管理、ロール、メール、ダッシュボード、管理画面、監査ログを最初から組み込んだ「再利用できる土台」です。Claude Codeを使うと、この土台を短時間で作れます。ただし、有料プロダクトにするなら「画面が動く」だけでは足りません。

特に危ないのは、認証と課金をサンプルのまま本番に近づけることです。テナント、つまり1つのアプリを複数の会社やチームで安全に分ける仕組みが曖昧だと、別チームのデータが見える事故になります。RBAC、つまり役割にもとづく権限管理が粗いと、請求担当者が管理者操作をできてしまいます。監査ログ、つまり「誰がいつ何をしたか」の記録がないと、障害や不正の調査ができません。

この記事では、Claude CodeにSaaSボイラープレートを作らせるときの実用的な設計を、Next.js App Router、TypeScript、Prisma、Stripe、Resendの例で整理します。公式情報は必ず原典で確認してください。基準にするのはClaude Code docsNext.js docsAuth.jsPrisma schema docsStripe webhooksResend docsOWASP Authentication Cheat Sheetです。

関連するClaudeCodeLab記事として、認証はClaude Codeで認証実装を安全に進める実践ガイド、権限設計はClaude Code RBAC実装、入力検証はClaude Code Zod validation、API設計はClaude Code API開発も合わせて確認してください。

まず売れる形から逆算する

ボイラープレートは開発者の近道ですが、販売できるテンプレートにするには「買った人が何を作れるか」まで決める必要があります。私はClaudeCodeLabの記事改善で、単に機能一覧を増やすよりも、利用場面、失敗例、検証方法を先に書いたほうが問い合わせにつながりやすいと感じました。SaaSテンプレートも同じです。

実用的なユースケースは少なくとも3つあります。

ユースケース必要な機能収益化の入口
個人開発のマイクロSaaSGoogleログイン、個人プラン、Stripe Checkout、利用量表示月額980円からの小さなプラン
B2Bチーム向けツールテナント、招待、ロール、請求担当、監査ログチーム単位の月額課金
会員コンテンツ・テンプレ販売購入者権限、ダウンロード履歴、メール通知、管理画面有料テンプレートと講座
社内AI業務ツールSSO、IP制限、承認フロー、操作ログ導入支援や運用コンサル

この時点で「ボイラープレートがあれば法務やセキュリティレビューが不要」とは言えません。Stripeの税務、返金、利用規約、個人情報、ログ保存期間、アクセス権限の棚卸しは、人間の確認が必要です。Claude Codeは実装の足場を早く組む道具であって、事業上の責任を肩代わりする道具ではありません。

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に渡すCLAUDE.md

Claude Codeへ「SaaSを作って」とだけ頼むと、画面とAPIが混ざったレビューしにくい差分になりがちです。CLAUDE.mdに作業ルールを置くと、生成されるコードの品質が安定します。ここでのharnessは「エージェントが安全に作業するための足場」と考えてください。

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

このルールは初心者ほど効果があります。「テナントIDをブラウザから受け取るな」「請求はOWNERかBILLINGだけ」という条件を先に書くことで、Claude Codeが自然にガード関数、テスト、監査ログを作る方向へ寄ります。

フォルダ構成は機能別に切る

最初に決めたいのは、どこに何を書くかです。小さいうちは何でもapp/apiへ置けますが、SaaSでは認証、請求、チーム、メール、監査がすぐ絡みます。機能別に分けると、Claude Codeに「billingだけ直して」と依頼しやすくなります。

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

Prismaスキーマの最小形

次のスキーマは、有料SaaSの土台として必要な関係をまとめたものです。Tenantは会社やチーム、Membershipはユーザーとテナントの関係、Subscriptionは課金状態、AuditLogは操作記録です。実案件では住所、請求書、利用量、データ保持期間を追加します。

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

環境変数と秘密情報を検証する

秘密情報は.env.exampleに名前だけを書き、本物の値はVercel、Cloudflare、AWS、GitHub Actionsなどの秘密情報ストアに置きます。env.tsで起動時に検証すると、Webhook署名の未設定やメールAPIキーの抜けを早く発見できます。

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

テナントとロールを必ずサーバーで確認する

テナントIDはURLやフォームから送れます。しかし、それを信じてはいけません。次の関数はログイン済みユーザーのMembershipをDBで確認し、必要なロールを満たさない場合は止めます。

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

初心者がよく失敗するのは、tenantIdをhidden inputへ入れて、そのまま更新処理に使うことです。ブラウザから来た値は改ざんできます。SaaSの更新処理は、必ずサーバー側でMembershipを引いてから実行します。

Stripe Webhookは冪等性を意識する

Webhookは外部サービスから届く通知です。同じイベントが複数回来ることも、順番が前後することもあります。したがって「一度しか来ない前提」のコードは危険です。少なくとも署名検証、metadata.tenantIdupsert、ログを入れます。

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

メールと監査ログを共通化する

メール送信は、招待、支払い失敗、パスワードリセット、管理者通知で何度も使います。監査ログも同じです。Claude Codeには「APIごとにベタ書きせず共通関数を使う」と指示しておくと、後からログ形式を変えやすくなります。

// 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 testは「ユーザー目線でこの機能を受け入れてよいか」を確認するテストです。Claude Codeに実装を頼む前に、最低限の受け入れ条件を書きます。

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

失敗例として多いのは、正常系だけをテストして「別テナントにアクセスできない」「請求担当以外は価格変更できない」「Webhookを2回受けても二重課金扱いにならない」を確認しないことです。有料SaaSでは、成功パスより拒否パスのほうが重要なことがあります。

デプロイ前チェックリスト

公開前は次を必ず確認します。

  • updatedDate、OGP画像、description、内部リンク、外部公式リンクが入っている
  • .env.exampleはあるが、本物の秘密情報は入っていない
  • すべてのDB更新にtenantIdチェックがある
  • OWNER、ADMIN、BILLING、MEMBER、VIEWERの違いがテストされている
  • Stripe Webhookの署名検証と冪等性がある
  • メール送信失敗時のリトライまたは手動再送導線がある
  • 管理画面と監査ログが本番で見られる
  • 利用規約、プライバシーポリシー、返金方針、税務表示を人間が確認した
  • READMEにローカル起動、seed、テスト、デプロイ手順がある

テンプレートとして売るなら同梱物を分ける

商品化するなら、コードだけでは弱いです。購入者が迷うのは、環境変数、Stripe設定、OAuth設定、初期データ、運用ルールです。次のようにパッケージを分けると、無料記事から有料商品、導入相談へ自然につながります。

パッケージ内容向いている読者
Free checklistCLAUDE.md雛形、環境変数一覧、公開前チェックまず試す個人開発者
Starter templateNext.js、Prisma、Auth.js、Stripe、Resend、テスト週末にMVPを作りたい人
Pro template管理画面、監査ログ、利用量課金、招待、ドキュメント有料SaaSを本気で出す人
Team rolloutリポジトリ診断、Claude Code研修、レビュー規約作成チーム導入したい会社

ClaudeCodeLabでは、無料チートシートClaude Codeテンプレート・教材研修・導入相談を用意しています。この記事の構成をそのまま実リポジトリへ入れたい場合は、CLAUDE.md、テスト、レビュー観点まで一緒に整備するのが近道です。

実際に試した結果

Masaが検証用の小さなSaaSリポジトリでこの順番を試したとき、最初に「ダッシュボードを作って」と頼んだ版では、別テナントのURLを直接開ける穴と、Stripe Webhookの署名検証漏れが残りました。先にCLAUDE.mdへテナント境界、ロール、監査ログ、受け入れテストを書いた版では、Claude Codeの差分が小さくなり、レビューで見る場所も明確になりました。結論はシンプルです。SaaSボイラープレートは、機能数よりも境界、テスト、運用ドキュメントで価値が決まります。

#Claude Code #SaaS #ボイラープレート #Next.js #Prisma #Stripe
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。