ANTIGRAVITY LABEN
記事一覧/Agents & Manager
Agents & Manager/2026-03-19上級

Antigravity で Next.js + Supabase + Stripe のサブスク基盤を組む — 任せた範囲と手で書いた範囲

Antigravity のエージェントに Next.js + Supabase の土台を組ませ、Stripe の Checkout と Webhook だけは手で書いた記録です。署名検証・冪等性・metadata の受け渡しなど、任せると壊れやすい箇所を実装コードで示します。

Antigravity359SaaS10Stripe14Next.js6SupabaseWebhook2サブスクリプション7個人開発97

課金だけが動かなかった夜

土台は半日で立ち上がりました。認証画面もダッシュボードも、Antigravity のエージェントに指示を投げるたびに形になっていきます。ところが決済に差しかかった途端、テストモードの Stripe から届く Webhook が全部 400 で弾かれました。

原因は、生成されたハンドラが stripe-signature ヘッダを読まずに JSON をパースしていたことでした。テストコードは通っています。ローカルでも動いて見えます。署名検証を通していないので、Stripe 側からの本物のリクエストだけが落ちる、という形の失敗でした。

以来、私はこの領域を線で区切っています。画面と型とテストはエージェントに預ける。お金が動く経路は自分の手で書く。その線引きの根拠と、実際に使っている実装を残しておきます。

スタックを Next.js + Supabase + Stripe にした理由

エージェント主導の開発では、スタックの「調べやすさ」がそのまま生成品質になります。学習データに厚く載っている構成ほど、初回出力が実装に近い形で返ってきます。

レイヤ採用この構成を選んだ理由
フロント / APINext.js(App Router)画面と API ルートが同じリポジトリに収まり、エージェントが参照すべき文脈が1箇所にまとまります
DB / 認証Supabase認証と行レベルセキュリティが DB 側に閉じるため、アプリ側の権限バグが本番データに届きにくくなります
決済Stripeサブスクリプションの状態遷移が API 側で完結し、こちらは反映だけを担えばよくなります

逆に、社内独自ライブラリや採用事例の少ないフレームワークを混ぜると、エージェントの出力を人間が全部読み直す羽目になります。エージェントに任せる前提の設計では、枯れた構成を選ぶことがそのまま速度になりました。

設計を先に書かせて、実装より前に読む

いきなり実装を頼むと、テーブル定義とAPIの前提がずれたまま大量のコードが出てきます。私は必ず、設計ドキュメントだけを先に出させて、それを読んでから実装に進みます。

Antigravity 2.11.0 では AGENTS.md とカスタムルールファイルの中で @path/to/file 記法が使えるようになりました。規約を1枚の巨大ファイルに積み上げず、役割ごとに分割して参照できます。

# AGENTS.md
 
## このプロジェクトの前提
@docs/architecture.md
@docs/db-schema.md
 
## 決済に関する規約
@docs/rules/payment.md
 
## 生成時の制約
- API ルートは app/api/**/route.ts に置いてください
- Supabase へのサーバー側アクセスは service role キーを使うこと
- app/api/webhooks/ 配下は自動生成の対象外とします

最後の1行が要点です。Webhook のディレクトリを明示的に対象外にしておくと、以降のタスクでエージェントがそこへ書き込もうとしなくなります。禁止領域をルールファイル側に置いておく形が、私の手元では最も安定しました。

エージェントの守備範囲をどう決めるかは、エージェントへの委譲境界を個人開発でどう引くか でも別の角度から扱っています。

課金状態をどこに置くか

サブスクリプションの真実は Stripe 側にあります。アプリ側の DB は、そのミラーでしかありません。この前提を崩すと、解約済みのユーザーが有料機能を使えたままになります。

create table public.subscriptions (
  user_id                uuid primary key references auth.users (id) on delete cascade,
  stripe_customer_id     text        not null,
  stripe_subscription_id text        not null unique,
  price_id               text        not null,
  status                 text        not null,
  current_period_end     timestamptz not null,
  cancel_at_period_end   boolean     not null default false,
  updated_at             timestamptz not null default now()
);
 
alter table public.subscriptions enable row level security;
 
-- 読み取りのみ本人に開放する。書き込みポリシーは意図的に作らない
create policy "read own subscription"
  on public.subscriptions
  for select
  using (auth.uid() = user_id);

書き込み用のポリシーを作らないところが肝心です。RLS が有効なテーブルは、ポリシーの無い操作が既定で拒否されます。結果として、このテーブルを更新できるのは RLS を迂回する service role キー、つまり Webhook ハンドラだけになります。クライアント側のコードがどれだけ書き換わっても、課金状態は改竄できません。

権限判定は status の文字列一致ではなく、期限も併せて見ます。

create or replace function public.has_active_subscription(uid uuid)
returns boolean
language sql
stable
as $$
  select exists (
    select 1
      from public.subscriptions
     where user_id = uid
       and status in ('active', 'trialing')
       and current_period_end > now()
  );
$$;

past_due を許可に含めるかどうかは、事業判断として分かれるところです。私は支払い失敗から数日は利用を続けられる形にしたかったので、猶予はアプリ側の分岐ではなく Stripe の Smart Retries と current_period_end の余白で吸収する方針にしました。判定ロジックを増やすほど、後から挙動を追えなくなります。

Checkout — 後続イベントで迷子にならないための metadata

Checkout セッションの作成は素直な処理です。ただし1点だけ、後から効いてくる落とし穴があります。

// app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
 
export async function POST(req: NextRequest) {
  const { userId, priceId, customerId } = await req.json();
  const origin = req.headers.get("origin") ?? process.env.NEXT_PUBLIC_SITE_URL!;
 
  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    customer: customerId,
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${origin}/account?checkout=success`,
    cancel_url: `${origin}/pricing`,
    client_reference_id: userId,
    metadata: { user_id: userId },
    // ここを忘れると customer.subscription.* イベントに user_id が乗りません
    subscription_data: {
      metadata: { user_id: userId },
    },
  });
 
  return NextResponse.json({ url: session.url });
}

metadata はセッションに付くだけで、そこから作られるサブスクリプションには引き継がれません。subscription_data.metadata を渡していないと、checkout.session.completed では紐付けできても、その後の customer.subscription.updateddeleted で誰のサブスクか分からなくなります。

先ほどの Webhook 事故を調べていたときに、生成コードの多くがこの行を落としていることに気づきました。初回の購読は成功し、解約だけが反映されない。テストが緑のまま本番で壊れる典型的な形です。両方に載せておけば、どのイベントからでも同じキーで辿れます。

Webhook — 署名検証と冪等性は手で書く

ここがこの記事の中心です。App Router では req.text() が生のリクエストボディを返します。req.json() で受けてから再度文字列化したものは署名検証に通りません。バイト列が一致しないためです。

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { createClient } from "@supabase/supabase-js";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
 
const admin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
  { auth: { persistSession: false } }
);
 
export async function POST(req: NextRequest) {
  const signature = req.headers.get("stripe-signature");
  if (!signature) {
    return NextResponse.json({ error: "missing signature" }, { status: 400 });
  }
 
  // 生のボディをそのまま渡す。json() で受けると検証に失敗します
  const rawBody = await req.text();
 
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      rawBody,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error("[stripe] signature verification failed", err);
    return NextResponse.json({ error: "invalid signature" }, { status: 400 });
  }
 
  // 主キー衝突を「処理済み」の判定に使う(アプリ側でロックを持たない)
  const { error: claimError } = await admin
    .from("processed_stripe_events")
    .insert({ event_id: event.id, type: event.type });
 
  if (claimError) {
    if (claimError.code === "23505") {
      return NextResponse.json({ received: true, duplicate: true });
    }
    console.error("[stripe] claim failed", claimError);
    return NextResponse.json({ error: "claim failed" }, { status: 500 });
  }
 
  try {
    await applyEvent(event);
  } catch (err) {
    // 失敗したら記録を戻し、Stripe のリトライで再処理させる
    await admin
      .from("processed_stripe_events")
      .delete()
      .eq("event_id", event.id);
    console.error("[stripe] handler failed", event.type, err);
    return NextResponse.json({ error: "handler failed" }, { status: 500 });
  }
 
  return NextResponse.json({ received: true });
}

冪等性のテーブルは、主キー制約だけの簡素なものです。

create table public.processed_stripe_events (
  event_id    text primary key,
  type        text        not null,
  received_at timestamptz not null default now()
);

Stripe は 2xx が返るまで同じイベントを再送します。ネットワークの揺れで応答だけが失われた場合、同じ event.id が再び届きます。主キー衝突(PostgreSQL のエラーコード 23505)を「既に処理済み」の合図として扱うと、アプリ側でロックを持たずに二重処理を防げます。

イベントの適用側は、状態を上書きする形に寄せました。

async function applyEvent(event: Stripe.Event) {
  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      if (!session.subscription) return;
      const sub = await stripe.subscriptions.retrieve(
        session.subscription as string
      );
      await upsert(sub);
      return;
    }
    case "customer.subscription.updated":
    case "customer.subscription.deleted": {
      await upsert(event.data.object as Stripe.Subscription);
      return;
    }
    default:
      return;
  }
}
 
async function upsert(sub: Stripe.Subscription) {
  const userId = sub.metadata?.user_id;
  if (!userId) throw new Error(`missing user_id on ${sub.id}`);
 
  const { error } = await admin.from("subscriptions").upsert(
    {
      user_id: userId,
      stripe_customer_id: sub.customer as string,
      stripe_subscription_id: sub.id,
      price_id: sub.items.data[0]?.price.id ?? "",
      status: sub.status,
      current_period_end: new Date(
        sub.items.data[0].current_period_end * 1000
      ).toISOString(),
      cancel_at_period_end: sub.cancel_at_period_end,
      updated_at: new Date().toISOString(),
    },
    { onConflict: "user_id" }
  );
 
  if (error) throw error;
}

イベントの順序は保証されません。差分を加算する実装にすると、到着順が入れ替わっただけで状態が壊れます。届いたイベントが指すサブスクリプションの現在値をそのまま上書きする形にしておけば、順序に依存しません。user_id が取れないときに例外を投げているのも意図的で、握り潰すより 500 を返して Stripe に再送させたほうが、取りこぼしに気づけます。

任せた作業と、任せなかった作業

十日ほど回してみて、線はかなりはっきりしました。個人開発では、レビューに回せる人手がありません。エージェントの出力量ではなく、読み直さずに済む範囲がどこまでかで役割を決めています。

作業担当判断の理由
画面コンポーネントとレイアウトエージェント誤りが目で見えるので、検収コストが低く済みます
Supabase の型生成と DB クライアントエージェント型が合わなければコンパイルが落ち、機械が検知できます
テーブル定義と RLS ポリシー下書きのみ委譲ポリシーの抜けは静かに通ってしまうため、最終判断は自分で持ちます
Checkout セッション作成手で確認して採用metadata の受け渡し漏れが後段で効いてきます
Webhook ハンドラ人間署名検証と冪等性は、テストが緑でも本番でだけ壊れます
環境変数とキーの配置人間service role キーがクライアント側へ漏れると取り返しがつきません

判断の軸は「間違いに機械が気づけるか」です。型エラーやテスト失敗として現れる領域は、遠慮なく預けられます。署名検証の欠落のように、成功したように見えたまま通過する領域は、人間が読むしかありません。

本番に出す前に見ている項目

デプロイ前の確認は、毎回この順で行っています。

  1. stripe listen --forward-to localhost:3000/api/webhooks/stripe でローカルに転送し、stripe trigger customer.subscription.deleted を流して DB の status が変わることを確認する
  2. 同じイベントを2回流し、2回目のレスポンスが duplicate: true になることを確認する
  3. STRIPE_WEBHOOK_SECRET をテスト用と本番用で取り違えていないか、Stripe ダッシュボードのエンドポイント設定と突き合わせる
  4. 匿名キーで subscriptions テーブルへ update を投げ、RLS に拒否されることを確認する
  5. SUPABASE_SERVICE_ROLE_KEYNEXT_PUBLIC_ の接頭辞を持たないことを、ビルド成果物の検索で確かめる

2 と 4 は、エージェントが書いたテストには含まれていませんでした。生成されたテストは、書いた本人が想定した経路しか通りません。想定の外側を確かめる手順は、自分で足す必要があります。

Supabase 側の設定を詰める段取りは、Antigravity と Supabase で本番実装—Auth・RLS・Edge Functions・Realtime の要点 にまとめてあります。

手を離す場所を決めておく

エージェントを入れて変わったのは、書く速度よりも、どこを自分で見るべきかがはっきりしたことでした。全部を書いていた頃は、注意の総量が均等に薄く散っていました。土台を預けられるようになって、決済経路とデータ境界に時間を集めておけるようになりました。

次に同じ構成を組むときは、AGENTS.md の禁止領域を最初の一手として書きます。生成が始まってから止めるより、始まる前に線を引いておくほうが、結局は早く着きます。実装の参考になれば幸いです。

シェア

お読みいただきありがとうございます

Antigravity Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥2,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

Agents & Manager2026-04-26
Antigravity マルチエージェントで継続課金 SaaS を作る — エージェント分担と購読収益の実装パターン
Antigravity のマルチエージェントを継続課金 SaaS の中核に据えるための実装と運用パターン。役割分担・タスクキュー・Stripe サブスク連携・コスト制御まで、動くコードと判断軸を体系的にまとめました。
Agents & Manager2026-05-05
AgentKit 2.0 でサブスク型AIエージェントサービスを作る — Stripe課金から月次収益設計まで
AgentKit 2.0を使ったサブスクリプション型AIエージェントサービスの構築方法を完全解説。Stripe課金統合・マルチエージェント設計・プライシング戦略・解約防止まで、月次収益を安定させる実装ガイドです。
Agents & Manager2026-04-14
Antigravity × AgentKit 2.0 × Stripe でつくるAIエージェント SaaS — 個人開発者が本番運用まで到達する実装ガイド
AgentKit 2.0 と Stripe を組み合わせ、サブスクリプション収益を得られる AI エージェント SaaS を Antigravity で構築する手順をまとめました。アーキテクチャ設計から課金・Webhook の冪等性、同時実行スロットが漏れる Redis 実装の穴まで、個人開発で実際につまずいた箇所を中心に整理しています。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →