取り組みの背景:なぜマルチテナント設計が難しいのか
個人開発で SaaS を立ち上げる際、最初の壁になるのがマルチテナント設計です。「テナント」とは SaaS を利用する組織や企業の単位のことで、それぞれが独立したデータ空間を持ちながら、同じインフラを共有する仕組みを作る必要があります。
テナント分離が不十分だと、あるテナントのデータが別のテナントに漏洩するという深刻なセキュリティ問題が発生します。一方で完全に独立したインフラを各テナントに提供すれば、コストが膨大になります。
Antigravity IDE を活用しながら以下の3つの柱でプロダクション品質のマルチテナント SaaS を構築する方法を順を追って整理していきます。
Supabase RLS :データベース層でのテナント分離
Stripe メーター課金 :API 使用量に連動した従量制課金
RBAC :組織内の役割ベース権限制御
対象読者は、すでに Next.js と Supabase の基礎を理解しており、本番 SaaS の構築に踏み込もうとしている中〜上級者です。
マルチテナント設計の3つのパターン
実装を始める前に、マルチテナントの設計パターンを整理しておきましょう。
パターン1:共有データベース・共有スキーマ(本記事で採用)
全テナントが同一のテーブルを使用し、tenant_id カラムでデータを分離します。コストが低く、Supabase の RLS と相性が抜群です。ほとんどの SaaS はこのパターンで十分です。
パターン2:共有データベース・個別スキーマ
テナントごとに別スキーマ(PostgreSQL の schema)を作成します。完全なデータ分離が実現しますが、マイグレーション管理が複雑になります。
パターン3:個別データベース
テナントごとに独立したデータベースを持ちます。最高のセキュリティを提供しますが、コストとオペレーションの負荷が大きいです。
Step 1:データベーススキーマ設計
Antigravity で新しいプロジェクトを開き、スキーマ設計から始めます。以下のプロンプトをエージェントに渡してください。
マルチテナント SaaS のデータベーススキーマを設計してください。
要件:
- organizations(テナント)テーブル
- users と organizations の多対多(メンバーシップ)
- roles: owner / admin / member
- プロジェクト・リソースはすべて organization_id で紐付け
- Supabase RLS に最適化した構造
エージェントが生成するベーススキーマは以下のようになります。
-- organizations テーブル(テナントの本体)
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL ,
slug TEXT NOT NULL UNIQUE , -- URL用スラッグ(例: "acme-corp")
plan TEXT NOT NULL DEFAULT 'free' , -- free / starter / pro / enterprise
stripe_customer_id TEXT UNIQUE ,
stripe_subscription_id TEXT ,
api_usage_this_month INTEGER DEFAULT 0 , -- 今月のAPI使用量
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW ()
);
-- organization_members(ユーザーとテナントの紐付け)
CREATE TABLE organization_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE ,
user_id UUID NOT NULL REFERENCES auth . users (id) ON DELETE CASCADE ,
role TEXT NOT NULL CHECK ( role IN ( 'owner' , 'admin' , 'member' )),
invited_by UUID REFERENCES auth . users (id),
accepted_at TIMESTAMPTZ ,
created_at TIMESTAMPTZ DEFAULT NOW (),
UNIQUE (organization_id, user_id)
);
-- projects テーブル(テナント内のリソース例)
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE ,
name TEXT NOT NULL ,
description TEXT ,
created_by UUID NOT NULL REFERENCES auth . users (id),
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW ()
);
-- api_usage_logs(Stripe メーター課金用の使用量ログ)
CREATE TABLE api_usage_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE ,
user_id UUID NOT NULL REFERENCES auth . users (id),
endpoint TEXT NOT NULL ,
tokens_used INTEGER NOT NULL DEFAULT 0 ,
created_at TIMESTAMPTZ DEFAULT NOW ()
);
インデックス設計
パフォーマンスのために不可欠なインデックスも忘れずに作成します。
-- RLS フィルタで使われる organization_id に必ずインデックスを貼る
CREATE INDEX idx_projects_org_id ON projects(organization_id);
CREATE INDEX idx_api_usage_logs_org_id ON api_usage_logs(organization_id);
CREATE INDEX idx_organization_members_user_id ON organization_members(user_id);
CREATE INDEX idx_organization_members_org_id ON organization_members(organization_id);
Step 2:Supabase RLS によるテナント完全分離
Row Level Security(RLS)はテナント分離の要です。PostgreSQL の RLS は、クエリの WHERE 句を自動的に付与する仕組みで、アプリケーション層でのミスに関わらずデータ漏洩を防ぎます。
Antigravity エージェントに RLS 設計を依頼する際のプロンプト:
以下のテーブルに Supabase RLS ポリシーを設定してください。
- organizations: メンバーのみ閲覧可能
- projects: 所属テナントのメンバーのみ CRUD 可能
- api_usage_logs: 所属テナントのメンバーが閲覧可能、INSERT は全認証ユーザー可(自分のレコードのみ)
- テナントの判定は organization_members テーブルを参照
helper 関数の定義
RLS ポリシーを DRY に書くために、ヘルパー関数を先に定義します。
-- 現在のユーザーが指定テナントのメンバーかどうかを返す関数
CREATE OR REPLACE FUNCTION auth .is_org_member(org_id UUID)
RETURNS BOOLEAN
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM organization_members
WHERE organization_id = org_id
AND user_id = auth . uid ()
AND accepted_at IS NOT NULL
);
END ;
$$;
-- 現在のユーザーのテナント内ロールを返す関数
CREATE OR REPLACE FUNCTION auth .get_org_role(org_id UUID)
RETURNS TEXT
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
member_role TEXT ;
BEGIN
SELECT role INTO member_role
FROM organization_members
WHERE organization_id = org_id
AND user_id = auth . uid ()
AND accepted_at IS NOT NULL ;
RETURN member_role;
END ;
$$;
projects テーブルの RLS ポリシー
-- RLS を有効化
ALTER TABLE projects ENABLE ROW LEVEL SECURITY ;
-- 閲覧:テナントメンバーのみ
CREATE POLICY "projects_select_policy" ON projects
FOR SELECT
USING ( auth . is_org_member (organization_id));
-- 作成:テナントメンバーなら誰でも可(created_by に自分のUIDを設定)
CREATE POLICY "projects_insert_policy" ON projects
FOR INSERT
WITH CHECK (
auth . is_org_member (organization_id)
AND created_by = auth . uid ()
);
-- 更新:admin 以上のみ
CREATE POLICY "projects_update_policy" ON projects
FOR UPDATE
USING (
auth . get_org_role (organization_id) IN ( 'owner' , 'admin' )
);
-- 削除:owner のみ
CREATE POLICY "projects_delete_policy" ON projects
FOR DELETE
USING (
auth . get_org_role (organization_id) = 'owner'
);
RLS のパフォーマンス最適化
RLS ポリシー内でサブクエリを使うと、クエリのたびにサブクエリが実行されてパフォーマンスが低下します。SECURITY DEFINER 関数でキャッシュするか、JWT の app_metadata にテナント情報を埋め込む方法が効果的です。
-- PostgreSQL の行レベルキャッシュを活用するための設定
-- jwt_secret を使って app_metadata からテナント情報を高速取得
CREATE OR REPLACE FUNCTION auth .get_user_orgs()
RETURNS UUID[]
LANGUAGE plpgsql SECURITY DEFINER STABLE -- STABLE でクエリ内でキャッシュ
AS $$
BEGIN
RETURN ARRAY (
SELECT organization_id FROM organization_members
WHERE user_id = auth . uid ()
AND accepted_at IS NOT NULL
);
END ;
$$;
Step 3:RBAC 権限制御の型安全実装
Next.js アプリケーション層では、型安全な RBAC を実装します。Antigravity のエージェントモードで以下の型定義ファイルを生成させましょう。
// src/types/rbac.ts
export type OrgRole = 'owner' | 'admin' | 'member' ;
// リソースタイプ
export type Resource =
| 'project'
| 'member'
| 'billing'
| 'api_key'
| 'settings' ;
// アクションタイプ
export type Action =
| 'create'
| 'read'
| 'update'
| 'delete'
| 'invite'
| 'manage' ;
// 権限マトリクス
const PERMISSIONS : Record < OrgRole , Record < Resource , Action []>> = {
owner: {
project: [ 'create' , 'read' , 'update' , 'delete' ],
member: [ 'create' , 'read' , 'update' , 'delete' , 'invite' ],
billing: [ 'create' , 'read' , 'update' , 'delete' , 'manage' ],
api_key: [ 'create' , 'read' , 'update' , 'delete' ],
settings: [ 'create' , 'read' , 'update' , 'delete' , 'manage' ],
},
admin: {
project: [ 'create' , 'read' , 'update' , 'delete' ],
member: [ 'read' , 'invite' ],
billing: [ 'read' ],
api_key: [ 'create' , 'read' , 'update' , 'delete' ],
settings: [ 'read' , 'update' ],
},
member: {
project: [ 'create' , 'read' , 'update' ], // 削除は不可
member: [ 'read' ],
billing: [ 'read' ],
api_key: [ 'read' ],
settings: [ 'read' ],
},
};
// 権限チェック関数
export function can (
role : OrgRole ,
resource : Resource ,
action : Action
) : boolean {
return PERMISSIONS [role][resource]?. includes (action) ?? false ;
}
// React Hook 版
export function usePermission ( role : OrgRole | null | undefined ) {
return {
can : ( resource : Resource , action : Action ) =>
role ? can (role, resource, action) : false ,
isOwner: role === 'owner' ,
isAdmin: role === 'admin' || role === 'owner' ,
isMember: !! role,
};
}
Server Action での権限チェック
Next.js App Router の Server Action で権限チェックを一元管理します。
// src/lib/auth-guard.ts
import { createServerClient } from '@supabase/ssr' ;
import { cookies } from 'next/headers' ;
import { can, OrgRole, Resource, Action } from '@/types/rbac' ;
export interface AuthContext {
userId : string ;
orgId : string ;
role : OrgRole ;
}
// 権限チェック付きの認証ガード
export async function withOrgAuth < T >(
orgId : string ,
resource : Resource ,
action : Action ,
handler : ( ctx : AuthContext ) => Promise < T >
) : Promise < T > {
const cookieStore = await cookies ();
const supabase = createServerClient (
process.env. NEXT_PUBLIC_SUPABASE_URL ! ,
process.env. NEXT_PUBLIC_SUPABASE_ANON_KEY ! ,
{ cookies: { getAll : () => cookieStore. getAll () } }
);
const { data : { user }, error : authError } = await supabase.auth. getUser ();
if (authError || ! user) {
throw new Error ( 'Unauthorized: Please sign in' );
}
// メンバーシップとロールの取得
const { data : membership , error : memberError } = await supabase
. from ( 'organization_members' )
. select ( 'role' )
. eq ( 'organization_id' , orgId)
. eq ( 'user_id' , user.id)
. not ( 'accepted_at' , 'is' , null )
. single ();
if (memberError || ! membership) {
throw new Error ( 'Forbidden: You are not a member of this organization' );
}
const role = membership.role as OrgRole ;
if ( ! can (role, resource, action)) {
throw new Error (
`Forbidden: ${ role } cannot ${ action } ${ resource }`
);
}
return handler ({ userId: user.id, orgId, role });
}
// 使用例(Server Action)
export async function deleteProject ( orgId : string , projectId : string ) {
'use server' ;
return withOrgAuth (orgId, 'project' , 'delete' , async ({ userId }) => {
const supabase = /* ... */ ;
const { error } = await supabase
. from ( 'projects' )
. delete ()
. eq ( 'id' , projectId)
. eq ( 'organization_id' , orgId);
if (error) throw error;
return { success: true };
});
}
フロントエンドでの権限ベース UI 制御
// src/components/ProjectActions.tsx
'use client' ;
import { usePermission } from '@/types/rbac' ;
import type { OrgRole } from '@/types/rbac' ;
interface ProjectActionsProps {
projectId : string ;
orgId : string ;
currentUserRole : OrgRole ;
onDelete : () => void ;
}
export function ProjectActions ({
projectId ,
orgId ,
currentUserRole ,
onDelete ,
} : ProjectActionsProps ) {
const { can } = usePermission (currentUserRole);
return (
< div className = "flex gap-2" >
{ can ( 'project' , 'update' ) && (
< button className = "btn-secondary" >編集</ button >
) }
{ can ( 'project' , 'delete' ) && (
< button
className = "btn-danger"
onClick = { onDelete }
>
削除
</ button >
) }
</ div >
);
}
Step 4:Stripe メーター課金の実装
従量制課金は SaaS の差別化要因になります。Stripe の Metered Billing(Usage-based pricing)を使って、API 呼び出し数やトークン消費量に応じた課金を実装します。
Antigravity エージェントへの指示:
Stripe メーター課金の実装を手伝ってください。
要件:
- 従量制プラン:1000 API コールごとに ¥200
- 月次上限:プランごとに設定(Free: 1000コール、Starter: 10000コール)
- リアルタイムの使用量追跡
- 月次請求の自動化
Stripe 商品・価格の設定
// scripts/setup-stripe-products.ts
// Stripe ダッシュボードまたは API で事前に設定
import Stripe from 'stripe' ;
const stripe = new Stripe (process.env. STRIPE_SECRET_KEY ! );
async function setupMeteredBilling () {
// 従量制プランの商品を作成
const product = await stripe.products. create ({
name: 'SaaS API Usage' ,
description: 'Pay per API call' ,
});
// メータリング価格の作成
const price = await stripe.prices. create ({
product: product.id,
currency: 'jpy' ,
recurring: {
interval: 'month' ,
usage_type: 'metered' , // 従量制
aggregate_usage: 'sum' , // 月次合計
},
unit_amount: 20 , // ¥20 per unit
transform_quantity: {
divide_by: 100 , // 100 コールごとに 1 unit
round: 'up' ,
},
});
console. log ( 'Product ID:' , product.id);
console. log ( 'Price ID:' , price.id); // これを .env に設定
}
使用量の記録(API Route)
// src/app/api/usage/record/route.ts
import { NextRequest, NextResponse } from 'next/server' ;
import Stripe from 'stripe' ;
import { createServerClient } from '@supabase/ssr' ;
const stripe = new Stripe (process.env. STRIPE_SECRET_KEY ! );
export async function POST ( req : NextRequest ) {
try {
const { orgId , endpoint , tokensUsed } = await req. json ();
// Supabase で組織の Stripe subscription ID を取得
const supabase = /* サーバークライアント初期化 */ ;
const { data : org , error } = await supabase
. from ( 'organizations' )
. select ( 'stripe_subscription_id, plan, api_usage_this_month' )
. eq ( 'id' , orgId)
. single ();
if (error || ! org) {
return NextResponse. json ({ error: 'Organization not found' }, { status: 404 });
}
// Free プランの上限チェック
if (org.plan === 'free' && org.api_usage_this_month >= 1000 ) {
return NextResponse. json (
{ error: 'Monthly limit reached. Please upgrade your plan.' },
{ status: 429 }
);
}
// Stripe に使用量を報告(メーター課金の核心)
if (org.stripe_subscription_id && org.plan !== 'free' ) {
const subscription = await stripe.subscriptions. retrieve (
org.stripe_subscription_id
);
const subscriptionItem = subscription.items.data[ 0 ];
// 使用量レコードを Stripe に送信
await stripe.subscriptionItems. createUsageRecord (
subscriptionItem.id,
{
quantity: Math. ceil (tokensUsed / 100 ), // 100トークン = 1単位
timestamp: Math. floor (Date. now () / 1000 ),
action: 'increment' ,
}
);
}
// Supabase に使用量ログを記録
const { error : logError } = await supabase
. from ( 'api_usage_logs' )
. insert ({
organization_id: orgId,
user_id: ( await supabase.auth. getUser ()).data.user?.id,
endpoint,
tokens_used: tokensUsed,
});
// 月次使用量カウンターを更新
await supabase
. from ( 'organizations' )
. update ({ api_usage_this_month: org.api_usage_this_month + 1 })
. eq ( 'id' , orgId);
return NextResponse. json ({ success: true });
} catch (error) {
console. error ( 'Usage recording error:' , error);
return NextResponse. json ({ error: 'Internal server error' }, { status: 500 });
}
}
Stripe Webhook でのサブスクリプション管理
// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server' ;
import Stripe from 'stripe' ;
import { createServiceRoleClient } from '@/lib/supabase-service' ;
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' ) ! ;
let event : Stripe . Event ;
try {
event = stripe.webhooks. constructEvent (body, signature, webhookSecret);
} catch (err) {
return NextResponse. json ({ error: 'Invalid signature' }, { status: 400 });
}
const supabase = createServiceRoleClient (); // RLS をバイパスするサービスロール
switch (event.type) {
case 'customer.subscription.created' :
case 'customer.subscription.updated' : {
const subscription = event.data.object as Stripe . Subscription ;
const customerId = subscription.customer as string ;
// プランを判定
const plan = getPlanFromPriceId (subscription.items.data[ 0 ].price.id);
await supabase
. from ( 'organizations' )
. update ({
stripe_subscription_id: subscription.id,
plan,
updated_at: new Date (). toISOString (),
})
. eq ( 'stripe_customer_id' , customerId);
break ;
}
case 'invoice.payment_succeeded' : {
const invoice = event.data.object as Stripe . Invoice ;
const customerId = invoice.customer as string ;
// 月次使用量カウンターをリセット
await supabase
. from ( 'organizations' )
. update ({ api_usage_this_month: 0 })
. eq ( 'stripe_customer_id' , customerId);
break ;
}
case 'customer.subscription.deleted' : {
const subscription = event.data.object as Stripe . Subscription ;
// プランを Free に降格
await supabase
. from ( 'organizations' )
. update ({
plan: 'free' ,
stripe_subscription_id: null ,
})
. eq ( 'stripe_subscription_id' , subscription.id);
break ;
}
}
return NextResponse. json ({ received: true });
}
function getPlanFromPriceId ( priceId : string ) : string {
const planMap : Record < string , string > = {
[process.env. STRIPE_STARTER_PRICE_ID ! ]: 'starter' ,
[process.env. STRIPE_PRO_PRICE_ID ! ]: 'pro' ,
[process.env. STRIPE_ENTERPRISE_PRICE_ID ! ]: 'enterprise' ,
};
return planMap[priceId] ?? 'free' ;
}
Step 5:テナントオンボーディングフロー
新規テナントの作成からサブスクリプション開始までのフローを自動化します。
// src/app/actions/onboarding.ts
'use server' ;
import Stripe from 'stripe' ;
import { createServerClient } from '@supabase/ssr' ;
import { cookies } from 'next/headers' ;
import { redirect } from 'next/navigation' ;
const stripe = new Stripe (process.env. STRIPE_SECRET_KEY ! );
export async function createOrganization ( formData : FormData ) {
const name = formData. get ( 'name' ) as string ;
const slug = formData. get ( 'slug' ) as string ;
const plan = formData. get ( 'plan' ) as string ;
const cookieStore = await cookies ();
const supabase = createServerClient ( /* ... */ );
const { data : { user }, error : authError } = await supabase.auth. getUser ();
if (authError || ! user) redirect ( '/signin' );
// スラッグの重複チェック
const { data : existing } = await supabase
. from ( 'organizations' )
. select ( 'id' )
. eq ( 'slug' , slug)
. single ();
if (existing) {
return { error: 'このスラッグはすでに使用されています。' };
}
// Stripe カスタマーの作成
const customer = await stripe.customers. create ({
email: user.email ! ,
name,
metadata: { org_slug: slug },
});
// トランザクション的に組織とメンバーシップを作成
const { data : org , error : orgError } = await supabase
. from ( 'organizations' )
. insert ({
name,
slug,
plan: 'free' , // 最初は無料プランで開始
stripe_customer_id: customer.id,
})
. select ()
. single ();
if (orgError) {
return { error: 'テナントの作成に失敗しました。' };
}
// 作成者を owner として追加
await supabase. from ( 'organization_members' ). insert ({
organization_id: org.id,
user_id: user.id,
role: 'owner' ,
accepted_at: new Date (). toISOString (),
});
// 有料プランを選択した場合は Stripe Checkout へ
if (plan !== 'free' ) {
const priceId = getPriceId (plan);
const session = await stripe.checkout.sessions. create ({
customer: customer.id,
mode: 'subscription' ,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${ process . env . NEXT_PUBLIC_APP_URL }/dashboard/${ slug }?setup=success` ,
cancel_url: `${ process . env . NEXT_PUBLIC_APP_URL }/onboarding?canceled=true` ,
metadata: { org_id: org.id },
});
redirect (session.url ! );
}
redirect ( `/dashboard/${ slug }` );
}
Step 6:ダッシュボードでの使用量可視化
テナントが自分の使用量をリアルタイムで確認できるダッシュボードを実装します。
// src/app/dashboard/[slug]/billing/page.tsx
import { createServerClient } from '@supabase/ssr' ;
import { cookies } from 'next/headers' ;
import Stripe from 'stripe' ;
interface UsageData {
current : number ;
limit : number ;
percentage : number ;
}
export default async function BillingPage ({
params ,
} : {
params : { slug : string };
}) {
const cookieStore = await cookies ();
const supabase = createServerClient ( /* ... */ );
// 組織データの取得
const { data : org } = await supabase
. from ( 'organizations' )
. select ( `
*,
organization_members!inner(role)
` )
. eq ( 'slug' , params.slug)
. single ();
if ( ! org) return < div >Not found</ div >;
const stripe = new Stripe (process.env. STRIPE_SECRET_KEY ! );
// Stripe から今月の使用量を取得
let stripeUsage = 0 ;
if (org.stripe_subscription_id) {
const subscription = await stripe.subscriptions. retrieve (
org.stripe_subscription_id
);
const usageRecord = await stripe.subscriptionItems. listUsageRecordSummaries (
subscription.items.data[ 0 ].id,
{ limit: 1 }
);
stripeUsage = usageRecord.data[ 0 ]?.total_usage ?? 0 ;
}
const planLimits : Record < string , number > = {
free: 1000 ,
starter: 10000 ,
pro: 100000 ,
enterprise: Infinity ,
};
const limit = planLimits[org.plan] ?? 1000 ;
const current = org.api_usage_this_month;
const percentage = limit === Infinity ? 0 : Math. round ((current / limit) * 100 );
return (
< div className = "p-6 space-y-6" >
< h1 className = "text-2xl font-bold" >利用状況・課金</ h1 >
{ /* 使用量プログレスバー */ }
< div className = "bg-white rounded-lg p-6 shadow-sm border" >
< h2 className = "text-lg font-semibold mb-4" >今月の API 使用量</ h2 >
< div className = "flex justify-between text-sm text-gray-600 mb-2" >
< span > { current. toLocaleString () } コール</ span >
< span > { limit === Infinity ? '無制限' : `${ limit . toLocaleString () } コール上限` } </ span >
</ div >
< div className = "w-full bg-gray-200 rounded-full h-3" >
< div
className = { `h-3 rounded-full transition-all ${
percentage >= 90 ? 'bg-red-500' :
percentage >= 70 ? 'bg-yellow-500' : 'bg-green-500'
}` }
style = { { width: `${ Math . min ( percentage , 100 ) }%` } }
/>
</ div >
{ percentage >= 80 && (
< p className = "mt-2 text-sm text-amber-600" >
⚠️ 使用量が上限の { percentage } % に達しています。
</ p >
) }
</ div >
{ /* 現在のプラン */ }
< div className = "bg-white rounded-lg p-6 shadow-sm border" >
< h2 className = "text-lg font-semibold mb-2" >現在のプラン</ h2 >
< p className = "text-3xl font-bold capitalize" > { org.plan } </ p >
{ org.plan === 'free' && (
< a
href = { `/dashboard/${ params . slug }/billing/upgrade` }
className = "mt-4 inline-block px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
>
プランをアップグレード
</ a >
) }
</ div >
</ div >
);
}
Step 7:本番環境での運用ノウハウ
テナント間のデータ漏洩を防ぐテスト
RLS が正しく機能しているかを確認するテストを Antigravity で生成しましょう。
// tests/rls-isolation.test.ts
import { createClient } from '@supabase/supabase-js' ;
describe ( 'RLS Tenant Isolation' , () => {
const tenant1User = { email: 'user@tenant1.com' , password: 'test1234' };
const tenant2User = { email: 'user@tenant2.com' , password: 'test1234' };
test ( 'tenant1 のユーザーは tenant2 のプロジェクトを閲覧できない' , async () => {
// tenant1 のユーザーでサインイン
const client1 = createClient ( SUPABASE_URL , SUPABASE_ANON_KEY );
await client1.auth. signInWithPassword (tenant1User);
// tenant2 のプロジェクトを取得しようとする
const { data , error } = await client1
. from ( 'projects' )
. select ( '*' )
. eq ( 'organization_id' , TENANT2_ORG_ID );
// RLS により空のデータが返る(エラーではなく空配列)
expect (data). toHaveLength ( 0 );
expect (error). toBeNull (); // RLS はエラーではなく空を返す
});
test ( '権限のないユーザーはプロジェクトを削除できない' , async () => {
// member ロールのユーザーでサインイン
const memberClient = createClient ( SUPABASE_URL , SUPABASE_ANON_KEY );
await memberClient.auth. signInWithPassword ({ email: 'member@tenant1.com' , password: '...' });
const { error } = await memberClient
. from ( 'projects' )
. delete ()
. eq ( 'id' , EXISTING_PROJECT_ID );
expect (error).not. toBeNull ();
});
});
Antigravity での大規模リファクタリング
プロジェクトが成長してスキーマ変更が必要になったとき、Antigravity の Planning Mode を活用します。
Antigravity Planning Mode へのプロンプト:
organization_members テーブルに permissions フィールド(JSONB)を追加して
カスタム権限(テナントレベルの細かい権限設定)をサポートしたい。
影響範囲:
1. database マイグレーション
2. RLS ポリシーの更新
3. auth-guard.ts の権限チェックロジック
4. 型定義ファイル
5. フロントエンドの UI
段階的な実装計画を立ててください。
公式ドキュメントに載っていない、運用で気づいた落とし穴
ここまでは「動く」実装でした。ここからは、Dolice Labs で 4 つのサイトに Stripe メンバーシップ(Pro 月額・Premium 永久ライセンス)を実装し、実際に課金トラフィックを流して初めて気づいた、ドキュメントの行間にある問題を共有します。設計段階では見えず、本番で初めて顔を出す類のものです。
落とし穴 1:RLS ポリシー内の auth.uid() がクエリごとに再評価される
Step 2 で書いた RLS ポリシーは正しく動きますが、テーブルが数万行を超えたあたりで急にダッシュボードが重くなりました。原因は、ポリシー内の auth.uid() が各行ごとに再評価される ことです。PostgreSQL のプランナはこれを安定関数と見なさず、行スキャンのたびに関数を呼び直します。
対処は、関数呼び出しをサブクエリで囲んで一度だけ評価させることです。
-- ❌ Before: auth.uid() が行ごとに評価される(実測 470ms / 5万行)
CREATE POLICY "members can read projects"
ON projects FOR SELECT
USING (
organization_id IN (
SELECT organization_id FROM organization_members
WHERE user_id = auth . uid ()
)
);
-- ✅ After: (SELECT auth.uid()) でプランナが initplan 化し一度だけ評価(実測 8ms / 同条件)
CREATE POLICY "members can read projects"
ON projects FOR SELECT
USING (
organization_id IN (
SELECT organization_id FROM organization_members
WHERE user_id = ( SELECT auth . uid ())
)
);
(SELECT auth.uid()) と囲むだけで、同じ 5 万行のテーブルに対するテナント絞り込みが 470ms から 8ms まで縮みました。約 60 倍です。Supabase の公式パフォーマンスガイドにも後から追記されましたが、最初に RLS を書くチュートリアルではまず触れられていません。私はこれに丸一晩気づかず、インデックス不足を疑って遠回りしました。
落とし穴 2:Stripe メーター課金は「二重計上」が静かに起きる
Step 4 の使用量記録 API は、リトライやクライアントの二度押し、Vercel のリージョン間リプレイで同じ使用量を 2 回 Stripe に送る ことがあります。メーター課金は加算(action: 'increment')なので、二重送信はそのまま請求額の水増しになり、しかもログを見ないと気づきません。
対処は、使用イベントごとに冪等キーを発行し、Stripe 側と自前 DB の両方で重複を弾くことです。
// 使用イベントに安定した冪等キーを付与する
const idempotencyKey = `usage_${ orgId }_${ endpoint }_${ requestId }` ;
// 1) 自前 DB で先に記録を試み、UNIQUE 制約で重複を弾く
const { error : dupErr } = await supabase
. from ( 'api_usage_logs' )
. insert ({ organization_id: orgId, request_id: requestId, /* ... */ });
if (dupErr?.code === '23505' ) {
// すでに記録済み。Stripe へは送らず冪等に成功扱い
return NextResponse. json ({ success: true , deduped: true });
}
// 2) Stripe への送信にも idempotencyKey を渡す(二重防御)
await stripe.subscriptionItems. createUsageRecord (
subscriptionItem.id,
{ quantity: Math. ceil (tokensUsed / 100 ), action: 'increment' },
{ idempotencyKey }
);
api_usage_logs に request_id の UNIQUE 制約を足し、Stripe SDK の第 3 引数に idempotencyKey を渡す。この二段構えにしてから、月次請求の誤差はゼロになりました。Stripe の冪等キーは 24 時間で失効するため、自前 DB の UNIQUE 制約が最後の砦になります。
落とし穴 3:月次リセットを invoice.payment_succeeded に紐づけると 1 日ずれる
Step 4 の Webhook では invoice.payment_succeeded で api_usage_this_month をリセットしていますが、これだと請求の確定タイミング(決済の成功)に依存します。決済リトライや銀行側の遅延で、リセットが課金期間の境界から数時間〜1 日ずれることがありました。利用者から見ると「月初なのにまだ先月分のカウンターが残っている」状態です。
実務では、使用量の判定そのものをサブスクリプションの current_period_start 起点 にするのが確実です。カウンターのリセットイベントに頼らず、api_usage_logs を current_period_start 以降で集計すれば、決済の遅延に左右されません。私は最終的に「カウンターは UI 表示の高速化用キャッシュ、課金判定は常に period 起点の集計を正」とする二層構造に落ち着きました。
運用チェックリスト
本番投入前に、最低限ここだけは確認してください。
すべての RLS ポリシーで auth.uid() を (SELECT auth.uid()) に置き換えたか
organization_id と RLS フィルタ対象カラムにインデックスがあるか
使用量記録 API に冪等キーと UNIQUE 制約の二重防御があるか
課金判定が period 起点の集計になっているか(カウンターのリセット頼みになっていないか)
customer.subscription.deleted でプラン降格と機能制限が即時反映されるか
サービスロールキーを使う Webhook が RLS をバイパスする箇所を限定できているか
このうち 1 と 3 は、見落とすと「動くのに、忍び寄るように壊れる」種類のバグです。私自身、累計 5,000 万 DL のアプリ事業で課金まわりの小さな取りこぼしがどれだけ後を引くかを学んできました。SaaS の従量課金は、その繊細さがさらに増す領域だと感じています。
まとめ
マルチテナント SaaS の構築は複雑に見えますが、Antigravity のエージェント機能を活用すれば、スキーマ設計から RLS ポリシー生成、Stripe 連携コードまで、試行錯誤の時間を大幅に短縮できます。
本記事で解説した3つの柱を整理します。
Supabase RLS :データベース層でのテナント分離を自動化。アプリ層のバグに関わらずデータ漏洩を防ぐ
Stripe メーター課金 :API 使用量に連動した従量制課金で、ユーザーの成長に合わせた収益化を実現
RBAC :型安全な権限制御でフロントエンドと Server Action を保護
次のステップとして、Antigravity × Supabase 本番完全ガイド でより高度な Supabase 活用法を、Antigravity × Stripe SaaS フルスタックガイド で決済フロー全体の実装を深掘りすることをおすすめします。
さらに詳しくマルチテナントアーキテクチャを