取り組みの背景 — なぜB2B SaaSが個人開発者にとって最もスケールしやすいか
個人開発やスモールチームでアプリをリリースする場合、B2C(消費者向け)よりもB2B(企業・チーム向け)のほうが収益化しやすいという現実があります。B2Bアプリは組織単位で契約されるため、1件の契約が継続する限り毎月安定した収益をもたらします。チームで使われるツールは「解約コストが高い」という性質もあり、チャーン(解約率)を低く保ちやすい点も魅力です。
しかしB2B SaaSを構築するには、B2Cとは異なる設計上の課題があります。
マルチテナント : 複数の組織(テナント)のデータを安全に分離して管理する
RBAC(ロールベースアクセス制御) : 組織内のメンバーに役割を付与し、画面や機能へのアクセスを制限する
組織課金 : 個人ではなく組織単位でのStripe課金・メンバー数連動プラン
招待フロー : 既存ユーザーが新しいメンバーを組織に招待する仕組み
ここで扱うのはRorkを使ってこれらすべてを実装する方法を、実際のコードとSupabase・Stripeのスキーマ設計を含めて丁寧に解説します。
対象読者は、すでにRorkの基本操作に慣れており、Supabaseを使ったデータベース連携の経験がある方を想定しています。Supabaseのリアルタイム機能と認証の活用ガイド も参照すると理解が深まります。
マルチテナントアーキテクチャの設計
テナント分離の3つの方式
マルチテナントには大きく3つの分離方式があります。
方式1: データベース分離型(Database per Tenant)
テナントごとに別々のデータベースを用意する方式です。セキュリティが最も高い一方、コストとメンテナンス負荷が大きいため、大企業向けエンタープライズプランに限定するのが一般的です。
方式2: スキーマ分離型(Schema per Tenant)
1つのデータベース内でテナントごとに別スキーマを使います。PostgreSQLでは各テナントに独自のRow Level Securityポリシーを定義できます。
方式3: 行レベル分離型(Shared Schema + RLS)
すべてのテナントが同じテーブルを共有し、organization_id カラムで区別する方式です。Supabaseと組み合わせる場合に最もコスパがよく、個人開発・スモールチームに最適です。
Supabaseのスキーマ設計
まずSupabaseダッシュボードのSQL Editorで以下のスキーマを作成します。
-- 組織テーブル
create table organizations (
id uuid primary key default gen_random_uuid(),
name text not null ,
slug text unique not null ,
plan text not null default 'free' , -- free / starter / growth / enterprise
stripe_customer_id text ,
stripe_subscription_id text ,
max_members int not null default 3 ,
created_at timestamptz default now ()
);
-- 組織メンバーテーブル(ユーザーと組織の紐付け・ロール管理)
create table organization_members (
id uuid primary key default gen_random_uuid(),
organization_id uuid references organizations(id) on delete cascade ,
user_id uuid references auth . users (id) on delete cascade ,
role text not null default 'member' , -- owner / admin / member / viewer
invited_by uuid references auth . users (id),
accepted_at timestamptz ,
created_at timestamptz default now (),
unique (organization_id, user_id)
);
-- 招待テーブル
create table invitations (
id uuid primary key default gen_random_uuid(),
organization_id uuid references organizations(id) on delete cascade ,
email text not null ,
role text not null default 'member' ,
token text unique not null default encode(gen_random_bytes( 32 ), 'hex' ),
invited_by uuid references auth . users (id),
expires_at timestamptz default now () + interval '7 days' ,
accepted_at timestamptz ,
created_at timestamptz default now ()
);
-- RLSを有効化
alter table organizations enable row level security ;
alter table organization_members enable row level security ;
alter table invitations enable row level security ;
Row Level Security(RLS)ポリシーの設定
-- ユーザーが所属する組織のみ参照可能
create policy "users can view their organizations"
on organizations for select
using (
id in (
select organization_id from organization_members
where user_id = auth . uid ()
and accepted_at is not null
)
);
-- organization_membersはメンバー自身が参照可能
create policy "members can view own org members"
on organization_members for select
using (
organization_id in (
select organization_id from organization_members
where user_id = auth . uid ()
and accepted_at is not null
)
);
-- owner/adminのみ招待の作成・管理が可能
create policy "admins can manage invitations"
on invitations for all
using (
organization_id in (
select organization_id from organization_members
where user_id = auth . uid ()
and role in ( 'owner' , 'admin' )
and accepted_at is not null
)
);
RBACの実装 — 4段階の権限モデル
ロールの定義
B2B SaaSでよく使われる4段階の権限モデルを定義します。
Owner : 組織の作成者。全権限を持ち、削除・解約もできる
Admin : 請求管理・メンバー招待・設定変更が可能
Member : 通常の機能をフルに利用できるが、管理系機能はアクセス不可
Viewer : 閲覧専用。編集・作成操作はできない
Rorkでの権限チェックフック
Rorkのプロンプトで以下のカスタムフックを生成させます。
// hooks/useOrganizationRole.ts
import { useState, useEffect } from 'react' ;
import { supabase } from '../lib/supabase' ;
import { useAuth } from './useAuth' ;
type Role = 'owner' | 'admin' | 'member' | 'viewer' ;
interface Permission {
canManageMembers : boolean ;
canManageBilling : boolean ;
canEditSettings : boolean ;
canCreateContent : boolean ;
canViewContent : boolean ;
}
const ROLE_PERMISSIONS : Record < Role , Permission > = {
owner: {
canManageMembers: true ,
canManageBilling: true ,
canEditSettings: true ,
canCreateContent: true ,
canViewContent: true ,
},
admin: {
canManageMembers: true ,
canManageBilling: true ,
canEditSettings: true ,
canCreateContent: true ,
canViewContent: true ,
},
member: {
canManageMembers: false ,
canManageBilling: false ,
canEditSettings: false ,
canCreateContent: true ,
canViewContent: true ,
},
viewer: {
canManageMembers: false ,
canManageBilling: false ,
canEditSettings: false ,
canCreateContent: false ,
canViewContent: true ,
},
};
export function useOrganizationRole ( organizationId : string ) {
const { user } = useAuth ();
const [ role , setRole ] = useState < Role | null >( null );
const [ permissions , setPermissions ] = useState < Permission | null >( null );
const [ loading , setLoading ] = useState ( true );
useEffect (() => {
if ( ! user || ! organizationId) return ;
const fetchRole = async () => {
const { data , error } = await supabase
. from ( 'organization_members' )
. select ( 'role' )
. eq ( 'organization_id' , organizationId)
. eq ( 'user_id' , user.id)
. single ();
if (data && ! error) {
const userRole = data.role as Role ;
setRole (userRole);
setPermissions ( ROLE_PERMISSIONS [userRole]);
}
setLoading ( false );
};
fetchRole ();
}, [user, organizationId]);
return { role, permissions, loading };
}
画面コンポーネントへの権限適用
// components/PermissionGuard.tsx
import React from 'react' ;
import { View, Text, StyleSheet } from 'react-native' ;
interface PermissionGuardProps {
hasPermission : boolean ;
children : React . ReactNode ;
fallback ?: React . ReactNode ;
}
export function PermissionGuard ({
hasPermission ,
children ,
fallback ,
} : PermissionGuardProps ) {
if ( ! hasPermission) {
return fallback ? (
<> {fallback} </>
) : (
< View style = {styles.container} >
< Text style = {styles.text} >
この操作を行う権限がありません
</ Text >
</ View >
);
}
return <>{children} </> ;
}
const styles = StyleSheet. create ({
container: {
padding: 16 ,
backgroundColor: '#FFF3CD' ,
borderRadius: 8 ,
margin: 16 ,
},
text: {
color: '#856404' ,
textAlign: 'center' ,
},
});
メンバー管理画面での使用例:
// screens/MembersScreen.tsx
import { useOrganizationRole } from '../hooks/useOrganizationRole' ;
import { PermissionGuard } from '../components/PermissionGuard' ;
export function MembersScreen ({ organizationId }) {
const { permissions , loading } = useOrganizationRole (organizationId);
if (loading) return < LoadingSpinner />;
return (
< ScrollView >
< MemberList organizationId = {organizationId} />
{ /* 招待ボタンはadmin以上のみ表示 */ }
< PermissionGuard hasPermission = {permissions?.canManageMembers ?? false} >
< InviteButton organizationId = {organizationId} />
</ PermissionGuard >
{ /* 請求管理はadmin以上のみ */ }
< PermissionGuard hasPermission = {permissions?.canManageBilling ?? false} >
< BillingSection organizationId = {organizationId} />
</ PermissionGuard >
</ ScrollView >
);
}
メンバー招待フローの実装
招待メールの送信
Supabase Edge Functionを使って招待メールを送信します。
// supabase/functions/send-invitation/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts' ;
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' ;
serve ( async ( req ) => {
const { organizationId , email , role } = await req. json ();
const supabase = createClient (
Deno.env. get ( 'SUPABASE_URL' ) ! ,
Deno.env. get ( 'SUPABASE_SERVICE_ROLE_KEY' ) !
);
// 招待レコードを作成
const { data : invitation , error } = await supabase
. from ( 'invitations' )
. insert ({
organization_id: organizationId,
email,
role,
})
. select ()
. single ();
if (error) {
return new Response ( JSON . stringify ({ error: error.message }), {
status: 400 ,
});
}
// 組織名を取得
const { data : org } = await supabase
. from ( 'organizations' )
. select ( 'name' )
. eq ( 'id' , organizationId)
. single ();
// Resendでメール送信(YOUR_RESEND_API_KEYはResendで取得したキーに置き換え)
const emailResponse = await fetch ( 'https://api.resend.com/emails' , {
method: 'POST' ,
headers: {
Authorization: `Bearer ${ Deno . env . get ( 'RESEND_API_KEY' ) }` ,
'Content-Type' : 'application/json' ,
},
body: JSON . stringify ({
from: 'noreply@yourapp.com' ,
to: email,
subject: `${ org ?. name }への招待が届いています` ,
html: `
<p>${ org ?. name } に招待されました。</p>
<p>以下のリンクをクリックして参加してください(有効期限: 7日間):</p>
<a href="https://yourapp.com/invite/${ invitation . token }">
招待を承認する
</a>
` ,
}),
});
return new Response ( JSON . stringify ({ success: true }), { status: 200 });
});
招待承認画面
// screens/AcceptInvitationScreen.tsx
import { useEffect, useState } from 'react' ;
import { supabase } from '../lib/supabase' ;
export function AcceptInvitationScreen ({ token }) {
const [ status , setStatus ] = useState < 'loading' | 'success' | 'error' | 'expired' >( 'loading' );
const { user } = useAuth ();
useEffect (() => {
acceptInvitation ();
}, [token, user]);
const acceptInvitation = async () => {
if ( ! user) {
// 未ログインの場合はサインアップ/ログイン画面へ
// ログイン後に招待tokenをdeeplinkで受け取る設計が理想
return ;
}
// トークンで招待を検索
const { data : invitation } = await supabase
. from ( 'invitations' )
. select ( '*' )
. eq ( 'token' , token)
. single ();
if ( ! invitation) {
setStatus ( 'error' );
return ;
}
// 有効期限チェック
if ( new Date (invitation.expires_at) < new Date ()) {
setStatus ( 'expired' );
return ;
}
// メンバーとして追加
const { error } = await supabase. from ( 'organization_members' ). insert ({
organization_id: invitation.organization_id,
user_id: user.id,
role: invitation.role,
invited_by: invitation.invited_by,
accepted_at: new Date (). toISOString (),
});
if ( ! error) {
// 招待を承認済みにする
await supabase
. from ( 'invitations' )
. update ({ accepted_at: new Date (). toISOString () })
. eq ( 'id' , invitation.id);
setStatus ( 'success' );
} else {
setStatus ( 'error' );
}
};
// ... UIレンダリング
}
組織単位のStripe課金実装
プラン設計
B2B SaaSでは「メンバー数に応じた従量課金」か「フラットレートのティア制」かを選ぶことになります。ここでは以下のシンプルなティア制を設計します。
Free : 最大3名、基本機能のみ
Starter(¥2,980/月) : 最大10名、全機能
Growth(¥9,800/月) : 最大50名、優先サポート
Enterprise(要問い合わせ) : 無制限
Stripe Checkoutの組織向け実装
// api/create-org-checkout/route.ts(Next.js App Router)
import Stripe from 'stripe' ;
import { NextRequest, NextResponse } from 'next/server' ;
import { createClient } from '@supabase/supabase-js' ;
const stripe = new Stripe (process.env. STRIPE_SECRET_KEY ! );
export async function POST ( req : NextRequest ) {
const { organizationId , plan , locale } = await req. json ();
const supabase = createClient (
process.env. NEXT_PUBLIC_SUPABASE_URL ! ,
process.env. SUPABASE_SERVICE_ROLE_KEY !
);
// 組織情報を取得
const { data : org } = await supabase
. from ( 'organizations' )
. select ( '*' )
. eq ( 'id' , organizationId)
. single ();
if ( ! org) {
return NextResponse. json ({ error: 'Organization not found' }, { status: 404 });
}
// Stripeカスタマーを取得または作成
let customerId = org.stripe_customer_id;
if ( ! customerId) {
const customer = await stripe.customers. create ({
name: org.name,
metadata: { organization_id: organizationId },
});
customerId = customer.id;
await supabase
. from ( 'organizations' )
. update ({ stripe_customer_id: customerId })
. eq ( 'id' , organizationId);
}
const PRICE_IDS : Record < string , string > = {
starter: process.env. STRIPE_STARTER_PRICE_ID ! ,
growth: process.env. STRIPE_GROWTH_PRICE_ID ! ,
};
// Checkout Sessionを作成
const session = await stripe.checkout.sessions. create ({
customer: customerId,
mode: 'subscription' ,
line_items: [
{
price_data: {
currency: locale === 'ja' ? 'jpy' : 'usd' ,
product_data: {
name: locale === 'ja'
? `${ plan === 'starter' ? 'Starter' : 'Growth'} プラン — ${ org . name }`
: `${ plan === 'starter' ? 'Starter' : 'Growth'} Plan — ${ org . name }` ,
images: [ 'https://yourapp.com/images/org-plan-product.png' ],
},
unit_amount: plan === 'starter' ? 2980 : 9800 ,
recurring: { interval: 'month' },
},
quantity: 1 ,
},
],
success_url: `${ process . env . NEXT_PUBLIC_APP_URL }/${ locale }/org/${ organizationId }/billing?session_id={CHECKOUT_SESSION_ID}` ,
cancel_url: `${ process . env . NEXT_PUBLIC_APP_URL }/${ locale }/org/${ organizationId }/billing` ,
metadata: {
organization_id: organizationId,
plan,
},
});
return NextResponse. json ({ url: session.url });
}
Webhookでサブスクリプション状態を同期
// api/stripe-webhook/route.ts
import Stripe from 'stripe' ;
import { NextRequest, NextResponse } from 'next/server' ;
import { createClient } from '@supabase/supabase-js' ;
const stripe = new Stripe (process.env. STRIPE_SECRET_KEY ! );
export async function POST ( req : NextRequest ) {
const body = await req. text ();
const sig = req.headers. get ( 'stripe-signature' ) ! ;
let event : Stripe . Event ;
try {
event = stripe.webhooks. constructEvent (
body,
sig,
process.env. STRIPE_WEBHOOK_SECRET !
);
} catch {
return NextResponse. json ({ error: 'Webhook error' }, { status: 400 });
}
const supabase = createClient (
process.env. NEXT_PUBLIC_SUPABASE_URL ! ,
process.env. SUPABASE_SERVICE_ROLE_KEY !
);
switch (event.type) {
case 'checkout.session.completed' : {
const session = event.data.object as Stripe . Checkout . Session ;
const organizationId = session.metadata?.organization_id;
const plan = session.metadata?.plan;
const maxMembers = plan === 'starter' ? 10 : plan === 'growth' ? 50 : 999 ;
await supabase
. from ( 'organizations' )
. update ({
plan: plan || 'free' ,
stripe_subscription_id: session.subscription as string ,
max_members: maxMembers,
})
. eq ( 'id' , organizationId);
break ;
}
case 'customer.subscription.deleted' : {
const subscription = event.data.object as Stripe . Subscription ;
const { data : org } = await supabase
. from ( 'organizations' )
. select ( 'id' )
. eq ( 'stripe_subscription_id' , subscription.id)
. single ();
if (org) {
await supabase
. from ( 'organizations' )
. update ({ plan: 'free' , stripe_subscription_id: null , max_members: 3 })
. eq ( 'id' , org.id);
}
break ;
}
}
return NextResponse. json ({ received: true });
}
Rorkでの組織管理UI設計
組織切り替えコンポーネント
複数組織に所属するユーザーが組織を切り替えるためのUIです。Rorkに以下のプロンプトを入力します。
組織切り替えドロワーを作成してください。
- ユーザーが所属する組織一覧を表示する
- 各組織にはアバター・名前・自分のロール(バッジ)を表示
- 現在選択中の組織にはチェックマークを表示
- 「新しい組織を作成」ボタンを最下部に配置
- Supabaseのorganization_membersテーブルから取得する
メンバー管理画面のデータ取得
// hooks/useOrgMembers.ts
import { useEffect, useState } from 'react' ;
import { supabase } from '../lib/supabase' ;
interface Member {
id : string ;
user_id : string ;
role : string ;
email : string ;
display_name : string ;
accepted_at : string | null ;
}
export function useOrgMembers ( organizationId : string ) {
const [ members , setMembers ] = useState < Member []>([]);
const [ loading , setLoading ] = useState ( true );
useEffect (() => {
const fetchMembers = async () => {
// メンバー情報とユーザープロファイルを結合
const { data , error } = await supabase
. from ( 'organization_members' )
. select ( `
id,
user_id,
role,
accepted_at,
profiles:user_id (
email,
display_name
)
` )
. eq ( 'organization_id' , organizationId)
. order ( 'created_at' , { ascending: true });
if (data && ! error) {
const formatted = data. map (( m : any ) => ({
id: m.id,
user_id: m.user_id,
role: m.role,
accepted_at: m.accepted_at,
email: m.profiles?.email ?? '' ,
display_name: m.profiles?.display_name ?? '' ,
}));
setMembers (formatted);
}
setLoading ( false );
};
fetchMembers ();
// リアルタイムサブスクリプション
const subscription = supabase
. channel ( `org-members-${ organizationId }` )
. on ( 'postgres_changes' , {
event: '*' ,
schema: 'public' ,
table: 'organization_members' ,
filter: `organization_id=eq.${ organizationId }` ,
}, fetchMembers)
. subscribe ();
return () => {
supabase. removeChannel (subscription);
};
}, [organizationId]);
return { members, loading };
}
よくあるエラーと対処法
RLSが効かずに全データが見えてしまう
原因:Supabaseクライアントの初期化に service_role キーを使っています。クライアントサイドでは必ず anon キーを使い、service_role はサーバーサイド(Edge Function・APIルート)のみに限定します。
// ❌ NG: クライアントサイドでservice_roleキーを使う
const supabase = createClient (url, process.env. SUPABASE_SERVICE_ROLE_KEY );
// ✅ OK: クライアントサイドはanonキーを使う
const supabase = createClient (url, process.env. NEXT_PUBLIC_SUPABASE_ANON_KEY );
招待メールのリンクからアプリに戻れない
原因:モバイルアプリのDeepLink設定が不足しています。Expo + Rorkの場合、app.json に scheme を設定し、招待URLを yourapp://invite/TOKEN 形式にします。
// app.json
{
"expo" : {
"scheme" : "yourapp" ,
"intentFilters" : [
{
"action" : "VIEW" ,
"data" : [{ "scheme" : "https" , "host" : "yourapp.com" , "pathPrefix" : "/invite" }],
"category" : [ "BROWSABLE" , "DEFAULT" ]
}
]
}
}
メンバー数がmax_membersを超えて追加できてしまう
メンバー追加前にチェックを入れる:
const { count } = await supabase
. from ( 'organization_members' )
. select ( '*' , { count: 'exact' , head: true })
. eq ( 'organization_id' , organizationId)
. not ( 'accepted_at' , 'is' , null );
if ((count ?? 0 ) >= org.max_members) {
throw new Error ( 'メンバー上限に達しています。プランをアップグレードしてください。' );
}
応用:監査ログの実装
B2B SaaSでは「誰がいつ何をしたか」を記録する監査ログが重要です。Supabaseのトリガーを使ってシンプルに実装できます。
-- 監査ログテーブル
create table audit_logs (
id uuid primary key default gen_random_uuid(),
organization_id uuid references organizations(id),
user_id uuid references auth . users (id),
action text not null , -- 'member.invited', 'member.removed', 'plan.upgraded', etc.
target_id uuid,
metadata jsonb,
created_at timestamptz default now ()
);
-- RLS: 同じ組織のadmin以上のみ参照可能
alter table audit_logs enable row level security ;
create policy "admins can view audit logs"
on audit_logs for select
using (
organization_id in (
select organization_id from organization_members
where user_id = auth . uid ()
and role in ( 'owner' , 'admin' )
and accepted_at is not null
)
);
個人開発者の視点から(実体験メモ)
ここまでの要点
ここで扱うのはRorkとSupabaseを組み合わせてB2B SaaSアプリを構築するための核心部分を解説しました。マルチテナントアーキテクチャの選択、Row Level SecurityによるデータベースレベルのSecurity、4段階RBACの実装、招待フロー、Stripeによる組織課金、そして監査ログまで、プロダクションレベルのB2B SaaSに必要な要素を体系的に網羅しました。
次のステップとして、Stripe課金フローの完全実装ガイドも参考にしながら、まずはFreeプランで小規模なチームに提供し、フィードバックを集めながら機能を拡充していくことをお勧めします。
B2B SaaS設計の全体像