Claude Code で SaaS の MVP を作る方法
週末のビルドログ。Next.js 16 アプリの土台を作り、Supabase の認証と Postgres を追加し、Stripe の課金を繋ぎ込み、Vercel にデプロイする。すべて Claude Code で。
先週末、Claude Code で小さなフィードバックボードのアプリを端から端まで作りました。ログインして、ボードを作り、機能リクエストを集め、投票し、月 $19 払えば 2 つ以上のボードを持てる。複雑なところは何もありません。そこが肝心なんです。以下は実際のビルドを順を追って、リポジトリに入った本物のコードとともに紹介します。
作る対象は、小さくて本物の SaaS を選ぶ
サンプルアプリの名前は Signal。公開型のフィードバックボードです。創業者がボードを作ってリンクを共有し、ユーザーが機能リクエストを投稿して、欲しいものに投票します。無料アカウントはボード 1 つ。有料アカウントはボード無制限。
週末で仕上がるくらい小さく、それでいて本物の SaaS に必要なすべての層に触れられるくらい完成度があります。認証、row-level security 付きのリレーショナルスキーマ、コア機能のループ、そして継続課金です。別のものを作るなら、以下の作業の形はそのまま当てはまります。ボードや投稿を、あなたのプロダクトのコアとなるオブジェクトに置き換えてください。
始める前に
Claude Code を開く前に、4 つそろえておく必要があります。
Node.js 20.9.0 以降。 Next.js 16 は Node 18 のサポートを打ち切ったからです:
node --versionClaude Code をグローバルにインストールし、Claude Pro または Max プランに入っていること。無料ティアでは Claude Code は動きません。
npm install -g @anthropic-ai/claude-codeSupabase・Stripe・Vercel のアカウント。 このくらいの規模のプロジェクトなら、どれも十分な無料枠があります。Supabase のプロジェクトと Stripe のアカウントは今のうちに作っておきましょう。次のステップで両方の API キーが必要になります。
GitHub のリポジトリ。 これがあると、最後の Vercel デプロイが手動アップロードではなく 1 回の push で済みます。
プロジェクトの土台を作る
Turbopack と Tailwind CSS v4 が最初から繋がった、まっさらな Next.js 16 プロジェクトから始めます。
npx create-next-app@latest signal --typescript --tailwind --app --turbopack
cd signal
npx shadcn@latest initこのビルドの残りが依存するパッケージを入れます。認証と Postgres 用の Supabase SSR クライアントと、課金用の Stripe SDK です。
npm install @supabase/ssr @supabase/supabase-js stripe zodClaude Code でプロジェクトを開きます:
claudeCLAUDE.md と AGENTS.md を書く
Claude Code はフレームワークの慣習を AGENTS.md から、プロジェクト固有のルールを CLAUDE.md から読みます。Next.js 16 の canary リリースなら両方とも自動生成されます。stable を使っているなら、同梱ドキュメントを指す 1 行だけの AGENTS.md を自分で作りましょう:
node_modules/next/dist/docs/次に CLAUDE.md を書きます。これが、毎セッションごとにスタック・ファイル構成・命名規則を Claude に推測させないためのファイルです。
@AGENTS.md
## Stack
- Next.js 16 with App Router (TypeScript)
- Tailwind CSS v4 with shadcn/ui components
- PostgreSQL via Supabase, with row-level security on every table
- Stripe for subscription billing
## File Conventions
- Server Components by default. "use client" only for interactivity.
- Supabase server client: lib/supabase/server.ts
- Supabase admin client (service role, webhook use only): lib/supabase/admin.ts
- Server Actions live next to the routes that use them, in actions.ts files
- Route handlers for webhooks only, under app/api/
## Commands
- Dev server: npm run dev
- Type check: npx tsc --noEmit
- Build: npm run build
## Proxy
- Auth checks live in proxy.ts, not middleware.ts (Next.js 16)row-level security の 1 行は、見た目以上に大事です。これを明記しておかないと、Claude はときどきテーブルを作って RLS を有効にし忘れます。すると Supabase では、そのテーブルの全行がデフォルトで誰からでも読める状態になってしまいます。
Plan mode でデータベーススキーマを設計する
コードを 1 行も書く前に、Plan mode でスキーマを詰めておきます。
claude --permission-mode plan "design the Postgres schema for Signal: boards owned by a user, posts on a board, and votes on a post. Free accounts get 1 board. Paid accounts get unlimited boards. Include row-level security policies."Claude は 4 つのテーブル(profiles・boards・posts・votes)、それらの間の外部キー、そして RLS のプランを返してきます。ボードと投稿は公開読み取り可(ボードのリンクが匿名の訪問者に対しても機能するように)ですが、書き込みには、そのリソースを所有する認証済みユーザーが必要です。何かが作られる前に、これをレビューしましょう。スキーマの判断は、テーブルに本物のデータが入ったあとで巻き戻すのが最もコストの高い判断です。
Supabase と Postgres をセットアップする
ダッシュボードから新しい Supabase プロジェクトを作り、SQL エディタでスキーマを流します。これが実際に出荷されたマイグレーションです:
-- profiles: one row per user, tracks plan status
create table profiles (
id uuid primary key references auth.users(id) on delete cascade,
plan text not null default 'free',
stripe_customer_id text,
created_at timestamptz default now()
);
-- boards: one feedback board per row
create table boards (
id uuid primary key default gen_random_uuid(),
owner_id uuid references auth.users(id) on delete cascade not null,
name text not null,
slug text unique not null,
created_at timestamptz default now()
);
-- posts: feature requests on a board
create table posts (
id uuid primary key default gen_random_uuid(),
board_id uuid references boards(id) on delete cascade not null,
title text not null,
body text,
vote_count int not null default 0,
created_at timestamptz default now()
);
-- votes: one vote per user per post
create table votes (
id uuid primary key default gen_random_uuid(),
post_id uuid references posts(id) on delete cascade not null,
voter_id uuid references auth.users(id) on delete cascade not null,
created_at timestamptz default now(),
unique (post_id, voter_id)
);
alter table profiles enable row level security;
alter table boards enable row level security;
alter table posts enable row level security;
alter table votes enable row level security;
create policy "Users manage their own profile"
on profiles for all
using (auth.uid() = id);
create policy "Boards are publicly readable"
on boards for select
using (true);
create policy "Owners manage their own boards"
on boards for insert, update, delete
using (auth.uid() = owner_id);
create policy "Posts are publicly readable"
on posts for select
using (true);
create policy "Authenticated users create posts"
on posts for insert
with check (auth.role() = 'authenticated');
create policy "Voters manage their own votes"
on votes for all
using (auth.uid() = voter_id);
-- auto-create a profile row on signup
create or replace function public.handle_new_user()
returns trigger as $$
begin
insert into public.profiles (id) values (new.id);
return new;
end;
$$ language plpgsql security definer;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
-- atomic vote increment, called from a Server Action
create or replace function increment_vote(target_post_id uuid)
returns void as $$
begin
update posts set vote_count = vote_count + 1 where id = target_post_id;
end;
$$ language plpgsql security definer;すべてのテーブルで RLS が有効になっていて、ポリシーが付いています。ボードと投稿は誰でも読めます。フィードバックボードの目的そのものが公開リンクだからです。ただし書き込めるのは所有者(投票については認証済みの投票者)だけです。Supabase の API 設定からプロジェクト URL と anon キーを取ってきて、.env.local に入れておきましょう。
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-keySupabase で認証を作る
アプリ全体は Supabase クライアント 2 つでカバーできます。ひとつは訪問者のセッションでサーバー上を動くもの、もうひとつは後で Stripe の webhook のために RLS をバイパスする service role キーのものです。
// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
},
},
}
);
}// lib/supabase/admin.ts
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
export function createAdminClient() {
return createSupabaseClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } }
);
}proxy.ts(middleware.ts ではありません。その名前は Next.js 16 で消えました)は、リクエストがページに届く前にセッションを確認して、ダッシュボードのルートを保護します。
// proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { createServerClient } from "@supabase/ssr";
export async function proxy(request: NextRequest) {
const response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
);
},
},
}
);
const {
data: { user },
} = await supabase.auth.getUser();
if (!user && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return response;
}
export const config = {
matcher: ["/dashboard/:path*"],
};ログインページそのものは、Server Action に紐づいたただのメールとパスワードのフォームです。凝ったところはありません。パスワードを完全に省きたいなら、magic link でも同じように機能します。
// app/login/actions.ts
"use server";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
export async function signIn(formData: FormData) {
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({
email: formData.get("email") as string,
password: formData.get("password") as string,
});
if (error) redirect("/login?error=invalid-credentials");
redirect("/dashboard");
}
export async function signUp(formData: FormData) {
const supabase = await createClient();
const { error } = await supabase.auth.signUp({
email: formData.get("email") as string,
password: formData.get("password") as string,
});
if (error) redirect("/login?error=signup-failed");
redirect("/dashboard");
}コア機能を作る:ボードとアップボート
ダッシュボードはユーザーのボードを一覧表示し、新しいボードを作れるようにします。無料アカウントはボード 1 つに制限されますが、これは UI だけでなく Server Action の中で強制します。
// app/dashboard/actions.ts
"use server";
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export async function createBoard(formData: FormData) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");
const { data: profile } = await supabase
.from("profiles")
.select("plan")
.eq("id", user.id)
.single();
const { count } = await supabase
.from("boards")
.select("id", { count: "exact", head: true })
.eq("owner_id", user.id);
if (profile?.plan === "free" && (count ?? 0) >= 1) {
redirect("/dashboard/billing?limit=reached");
}
const name = formData.get("name") as string;
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40);
await supabase.from("boards").insert({ owner_id: user.id, name, slug });
redirect("/dashboard");
}公開ボードページは、async な params が効いてくる場所です。Next.js 16 では params は Promise なので、slug を読む前に await params が必要です。
// app/b/[slug]/page.tsx
import { createClient } from "@/lib/supabase/server";
import { VoteButton } from "./vote-button";
import { notFound } from "next/navigation";
interface PageProps {
params: Promise<{ slug: string }>;
}
export default async function BoardPage({ params }: PageProps) {
const { slug } = await params;
const supabase = await createClient();
const { data: board } = await supabase
.from("boards")
.select("id, name")
.eq("slug", slug)
.single();
if (!board) notFound();
const { data: posts } = await supabase
.from("posts")
.select("id, title, body, vote_count")
.eq("board_id", board.id)
.order("vote_count", { ascending: false });
return (
<main className="max-w-2xl mx-auto py-12 px-4">
<h1 className="text-2xl font-bold mb-6">{board.name}</h1>
<ul className="space-y-3">
{posts?.map((post) => (
<li key={post.id} className="flex gap-4 border rounded-lg p-4">
<VoteButton postId={post.id} initialCount={post.vote_count} />
<div>
<p className="font-medium">{post.title}</p>
{post.body && (
<p className="text-sm text-muted-foreground">{post.body}</p>
)}
</div>
</li>
))}
</ul>
</main>
);
}投票はクリックに反応するので、小さな Client Component が必要です。投票そのものは Server Action を通して走らせるので、RLS のチェックはブラウザではなくサーバー上で行われます。
// app/b/[slug]/vote-button.tsx
"use client";
import { useState, useTransition } from "react";
import { castVote } from "./actions";
export function VoteButton({
postId,
initialCount,
}: {
postId: string;
initialCount: number;
}) {
const [count, setCount] = useState(initialCount);
const [isPending, startTransition] = useTransition();
return (
<button
disabled={isPending}
onClick={() =>
startTransition(async () => {
setCount((c) => c + 1);
await castVote(postId);
})
}
className="flex flex-col items-center justify-center w-12 h-12 rounded-md border hover:bg-accent"
>
<span className="text-sm font-semibold">{count}</span>
</button>
);
}// app/b/[slug]/actions.ts
"use server";
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export async function castVote(postId: string) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");
const { error } = await supabase
.from("votes")
.insert({ post_id: postId, voter_id: user.id });
if (!error) {
await supabase.rpc("increment_vote", { target_post_id: postId });
}
}ここで本当の仕事をしているのは、スキーマの unique (post_id, voter_id) 制約です。ユーザーが 2 回投票すると insert が失敗し、カウントは増えず、二重投票を防ぐための追加のアプリケーションロジックは要りません。
Pro プラン向けに Stripe Checkout を追加する
まず Stripe のダッシュボードで商品と継続課金の価格を作ってから、checkout フローを繋ぎます。checkout を始めるのは、Stripe へ直接リダイレクトする Server Action です。
// app/dashboard/billing/actions.ts
"use server";
import Stripe from "stripe";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function startCheckout() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: process.env.STRIPE_PRO_PRICE_ID!, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?upgraded=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
client_reference_id: user.id,
metadata: { supabase_user_id: user.id },
});
redirect(session.url!);
}プランが実際に変わるのは、成功時のリダイレクトではなく、Stripe が webhook を通じてサブスクリプションを確認したときだけです。リダイレクトは偽装されたり途中で切れたりし得ます。webhook が信頼できる情報源です。
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { NextRequest, NextResponse } from "next/server";
import { createAdminClient } from "@/lib/supabase/admin";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const supabase = createAdminClient();
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.supabase_user_id;
if (userId) {
await supabase
.from("profiles")
.update({
plan: "pro",
stripe_customer_id: session.customer as string,
})
.eq("id", userId);
}
}
if (event.type === "customer.subscription.deleted") {
const subscription = event.data.object as Stripe.Subscription;
await supabase
.from("profiles")
.update({ plan: "free" })
.eq("stripe_customer_id", subscription.customer as string);
}
return NextResponse.json({ received: true });
}テスト中は Stripe CLI でイベントをローカルサーバーに転送し、表示される署名シークレットを STRIPE_WEBHOOK_SECRET にコピーしましょう。
stripe listen --forward-to localhost:3000/api/webhooks/stripe課金ページ自体はほとんど変わらないので、Next.js 16 で experimental.dynamicIO に取って代わった "use cache" ディレクティブを試すのにちょうどいい場所です。
// app/pricing/page.tsx
"use cache";
export default function PricingPage() {
return (
<main className="max-w-2xl mx-auto py-16 px-4">
<h1 className="text-3xl font-bold mb-8">Pricing</h1>
<div className="grid grid-cols-2 gap-6">
<div className="border rounded-lg p-6">
<h2 className="font-semibold">Free</h2>
<p className="text-sm text-muted-foreground">1 board, unlimited posts</p>
</div>
<div className="border rounded-lg p-6">
<h2 className="font-semibold">Pro ($19/mo)</h2>
<p className="text-sm text-muted-foreground">Unlimited boards</p>
</div>
</div>
</main>
);
}出荷前の品質ゲート
すべてのコミットの前に、例外なく 2 つのチェックを走らせます。
npx tsc --noEmit
npm run buildwebhook と課金フローを繋いだあとは、この両方を Claude Code に実行させましょう。Stripe の型は厳格で、最初のパスでは少しずれやすいからです。
claude "run tsc --noEmit and fix any type errors, then confirm the build passes"ここは、フローを実際に手で確認する場所でもあります。サインアップし、ボードを作り、無料プランの上限に当たり、Stripe のテストモードでアップグレードし、webhook がプランを pro に切り替えることを確認する。どれも型チェッカーでは捕まえられません。実際にクリックして通す必要があります。
Vercel にデプロイする
リポジトリを GitHub に push してから、Vercel にインポートします。最初のデプロイの前に、.env.local のすべての環境変数を Vercel のダッシュボードに設定しましょう。Stripe のキーと Supabase の service role キーも含めてです。
npx vercel env add SUPABASE_SERVICE_ROLE_KEY production
npx vercel env add STRIPE_SECRET_KEY production
npx vercel env add STRIPE_WEBHOOK_SECRET production
npx vercel --prod見落とされがちな点がひとつ。ローカルの stripe listen セッションで得た Stripe の webhook シークレットは、本番 URL を指す本物のエンドポイントを Stripe ダッシュボードで登録したときに得られるものとは別物です。最初のデプロイのあとにそのエンドポイントを作り、Vercel の STRIPE_WEBHOOK_SECRET を新しいシークレットに更新して、再デプロイしましょう。
連携されたパイプラインはどう見えるか
上のような Claude Code の 1 セッションは、週末プロジェクトなら問題なくこなせます。ボトルネックはあなたです。プランをレビューし、RLS ポリシーを読み、checkout フローを手でクリックして通す。これはエージェントがどれだけ優秀でも変わりませんし、機能 4 つ分としては妥当なレビュー量です。
これが妥当でなくなるのは、機能が 4 つではなく 20 になったときです。そのギャップのために作られたのが $29 の Code Kit です。Claude Code の上に載せる harness で、機能ごとに plan・build・evaluate・test を自動で回し、品質ゲート(型エラーゼロ、lint エラーゼロ、クリーンなビルド)を出荷前に強制します。上の npx tsc --noEmit のステップは、それと同じゲートを手で 1 回走らせているものです。パイプラインでは、あなたが頼まなくても、機能ひとつごとに毎回走ります。
Posted by @speedy_devv
Test-Driven Development
Make Claude write failing tests from your spec, then implement until green without cheating. How to wire testing into the agent loop so quality is enforced, not hoped for.
Claude Code で認証を追加する(Supabase Auth)
Claude Code と Supabase Auth を使って、Next.js 16 アプリにメール/パスワードのサインアップ、Google OAuth、magic link、保護されたルート、セッション管理を追加する方法。

