マルチテナントNext.jsとSupabase RLS: 本番環境ガイド
Supabase RLSを使用したマルチテナンシーが必要な理由
正直に言いましょう。SaaSアプリでマルチテナンシーを扱う場合、選択肢があります。テナントごとに別々のデータベースを立ち上げることもできます。これは組織的な天国のように聞こえますが、非常に高くつき、管理は本当に大変です。または、別々のスキーマを使用することもできます。これは運用上の手間が少なくなりますが、マイグレーションに関しては依然として簡単ではありません。しかし、SaaS の世界の人気者があります。行レベルのフィルタリングを備えた共有テーブルです。Supabase は PostgreSQL ネイティブの Row Level Security (RLS) のおかげで、このアプローチを簡単にしています。
なぜそれが重要なのでしょうか?シンプルです。データのフィルタリングはデータベースレベルで発生します。Next.js API ルートの WHERE 句で失敗しても、データベース自体がセーフティネットであるため、データ漏洩について夜中に起きている必要はありません。そして本当に、今の時代では、それは贅沢ではなく必要性です。
しかし、自分自身に嘘をつきましょう。RLS はクエリにオーバーヘッドを追加し、デバッグを複雑にし、マイグレーション中に問題が発生する可能性があります。では、異なるマルチテナンシーアプローチはどのように比較されるのでしょうか?
| アプローチ | 分離レベル | コスト | 運用の複雑性 | クエリパフォーマンス |
|---|---|---|---|---|
| テナント別データベース | 完全 | 高 ($50-200/テナント/月) | 非常に高い | 最高 |
| テナント別スキーマ | 強い | 中程度 | 高い (マイグレーション) | 良好 |
| 共有テーブル + RLS | 行レベル | 低い | 中程度 | 良好 (注意点あり) |
| アプリケーションレベルのフィルタリング | なし | 最低 | 低い | 最高 |
テナント数が 10,000 未満のほとんどの SaaS 製品では、RLS を備えた共有テーブルが最高のコストパフォーマンスを提供します。これがここで説明するものです。

アーキテクチャパターン: 共有対分離
コードの記述を考える前に、テナント解決戦略を選択する必要があります。実際には、主に 2 つのパターンが見つかります。
サブドメインベースのテナンシー
tenant-slug.yourapp.com を見たことがありますか?これは B2B SaaS で最も一般的なパターンへようこそ。これはスムーズで、プロフェッショナルで、ミドルウェアでのテナント解決を簡単にします。
パスベースのテナンシー
これは基本的な /org/tenant-slug/dashboard です。ワイルドカード DNS がないため設定が簡単で、Vercel などのプラットフォームでカスタムドメインなしで動作します。しかし正直に言うと、靴下とサンダルを履いているような感じです。本番環境の B2B アプリの場合はサブドメインベースを推奨し、内部ツールまたは MVP の場合はパスベースを推奨します。後で切り替える?あなたは過去の自分を呪うでしょう。これらのパターンを変更することは簡単ではありません。
テナントスキーマの設定
3 つの異なる本番環境でテスト済みのスキーマパターンです。
-- コアテナントテーブル
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',
created_at TIMESTAMPTZ DEFAULT now(),
settings JSONB DEFAULT '{}'
);
-- メンバーシップジャンクションテーブル
CREATE TABLE memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(user_id, org_id)
);
-- テナント スコープテーブルの例
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE NOT NULL,
name TEXT NOT NULL,
description TEXT,
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ DEFAULT now()
);
-- org_id のインデックス — EVERY テナント スコープテーブルに必要です
CREATE INDEX idx_projects_org_id ON projects(org_id);
CREATE INDEX idx_memberships_user_id ON memberships(user_id);
CREATE INDEX idx_memberships_org_id ON memberships(org_id);
memberships テーブルはすべてを一緒に保持する接着剤です。すべての RLS ポリシーはまるで彼らのお気に入りのいとこのようにそれを指します。ユーザーは複数の組織に参加でき、彼らのロールが何ができるか、何ができないかを決めます。そして、知恵の小さな宝石があります。EVERY テナント スコープテーブルに org_id をインデックスしてください。そうしないと、大量のデータで泳いでいるとクエリが糖蜜のように這う様子を見てください。クライアントのダッシュボードが 100,000 行で 50ms から 8 秒に低下したときに奇襲されました。教訓を得ました。
実際にスケールする RLS ポリシー
ここは、チュートリアルが典型的に弓を下ろす場所です。あなたを放り出すままにします。彼らは auth.uid() = user_id をあなたに投げかけ、「幸運を祈ります!」と言います。しかし、マルチテナント RLS はそのように要約することはできません。
ヘルパー関数パターン
すべてのポリシーにメンバーシップチェックを詰め込む理由?代わりにヘルパー関数を使用します。
-- ヘルパー: 現在のユーザーが org のメンバーであることを確認
CREATE OR REPLACE FUNCTION public.is_member_of(org UUID)
RETURNS BOOLEAN AS $$
SELECT EXISTS (
SELECT 1 FROM memberships
WHERE user_id = auth.uid()
AND org_id = org
);
$$ LANGUAGE sql SECURITY DEFINER STABLE;
-- ヘルパー: org 内のユーザーのロールを取得
CREATE OR REPLACE FUNCTION public.get_role_in(org UUID)
RETURNS TEXT AS $$
SELECT role FROM memberships
WHERE user_id = auth.uid()
AND org_id = org
LIMIT 1;
$$ LANGUAGE sql SECURITY DEFINER STABLE;
なぜ SECURITY DEFINER ですか?関数はクリエーターの特権で実行されるため、memberships テーブルの RLS をスキップします。これがないと、memberships の RLS が他のテーブルが依存するメンバーシップチェックをクラッシュさせる循環依存ウサギの穴に陥るリスクがあります。
そして STABLE パーツ?クエリ プランナーに、単一のクエリ中に同じ入力に対して関数の出力が一貫していることを示します。これは素晴らしいキャッシング利点を可能にします。IMMUTABLE を使用しようとしていますか?しないでください。メンバーシップはトランザクション間で変わることができます。
テナント スコープテーブルのポリシー
projects テーブルのポリシーを見てみましょう。
-- RLS を有効にする
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- SELECT: メンバーは自分の org のプロジェクトを表示できます
CREATE POLICY "Members can view org projects"
ON projects FOR SELECT
USING (public.is_member_of(org_id));
-- INSERT: 管理者と所有者がプロジェクトを作成できます
CREATE POLICY "Admins can create projects"
ON projects FOR INSERT
WITH CHECK (
public.get_role_in(org_id) IN ('owner', 'admin')
);
-- UPDATE: 管理者と所有者がプロジェクトを更新できます
CREATE POLICY "Admins can update projects"
ON projects FOR UPDATE
USING (public.is_member_of(org_id))
WITH CHECK (
public.get_role_in(org_id) IN ('owner', 'admin')
);
-- DELETE: 所有者のみがプロジェクトを削除できます
CREATE POLICY "Owners can delete projects"
ON projects FOR DELETE
USING (
public.get_role_in(org_id) = 'owner'
);
メンバーシップテーブル自体のポリシー
これは厄介です。memberships テーブルは独自の RLS を取得しますが、ヘルパー関数を使用することはできません。なぜなら、彼らは次に memberships をクエリするからです。循環参照の悪夢をキューイングします。
ALTER TABLE memberships ENABLE ROW LEVEL SECURITY;
-- ユーザーは属する org のメンバーシップを表示できます
CREATE POLICY "Users can view org memberships"
ON memberships FOR SELECT
USING (
org_id IN (
SELECT org_id FROM memberships WHERE user_id = auth.uid()
)
);
-- 所有者のみがメンバーを追加できます
CREATE POLICY "Owners can add members"
ON memberships FOR INSERT
WITH CHECK (
org_id IN (
SELECT org_id FROM memberships
WHERE user_id = auth.uid() AND role = 'owner'
)
);
はい、同じテーブルにサブクエリがあります。そして yes、PostgreSQL はそれを釘付けにします。サブクエリは自分のメンバーシップをチェックし、RLS は外側のクエリの周りにのみラップされるため、定義されるポリシーの影響を受けません。しかし、これをテストしてください。本番環境でバグを見つけたくはありません。

テナント解決のための Next.js ミドルウェア
Next.js 15 ときれいな App Router を使用して、エッジで実行されるミドルウェアは、テナント解決の完璧な大家です。サブドメインベースのセットアップのための私たちの信頼できるパターンは次のとおりです。
// middleware.ts
import { createServerClient } from '@supabase/ssr';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const PUBLIC_ROUTES = ['/login', '/signup', '/invite'];
export async function middleware(request: NextRequest) {
const hostname = request.headers.get('host') || '';
const currentHost = hostname.split('.')[0];
// メインドメインと localhost をスキップ
const isMainDomain = currentHost === 'app' || currentHost === 'www' || currentHost === 'localhost:3000';
let response = NextResponse.next({
request: { headers: request.headers },
});
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
request.cookies.set(name, value);
response.cookies.set(name, value, options);
});
},
},
}
);
const { data: { user } } = await supabase.auth.getUser();
if (!isMainDomain) {
response.headers.set('x-tenant-slug', currentHost);
if (!user && !PUBLIC_ROUTES.some(r => request.nextUrl.pathname.startsWith(r))) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|api/webhooks).*)'],
};
x-tenant-slug ヘッダーは純粋な金です。サーバーコンポーネントと API ルートに、どのテナントを扱っているか知らせるために使用します。Next.js プロジェクトで協力している場合](/capabilities/nextjs-development)、これを設定することは私たちの初日の優先事項です。
マルチテナントアプリの認証フロー
Supabase Auth はマルチテナンシーゲームで中立的です。ユーザーはグローバル領域に存在します。テナント関係はあなたのパズルです。ここが私たちのゲームプランです。
- ユーザーがサインアップ: 認証ユーザーを作成し、組織を構築し、「所有者」ロールでメンバーシップを生成します。
- ユーザーが招待されます: 管理者は保留中の招待を作成し、新しいユーザーが招待リンク経由で参加し、poof — 指定されたロールでメンバーシップが表示されます。
- ユーザーがログイン: サブドメインからテナントを抽出し、メンバーシップを確認し、ダッシュボードへエスコート。
// app/api/auth/signup/route.ts
import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const { email, password, orgName, orgSlug } = await request.json();
const supabase = await createClient();
// ユーザーをサインアップ
const { data: authData, error: authError } = await supabase.auth.signUp({
email,
password,
});
if (authError) return NextResponse.json({ error: authError.message }, { status: 400 });
// org 作成にはサービスロールクライアントを使用 (RLS をバイパス)
const adminClient = createAdminClient();
const { data: org, error: orgError } = await adminClient
.from('organizations')
.insert({ name: orgName, slug: orgSlug })
.select()
.single();
if (orgError) return NextResponse.json({ error: orgError.message }, { status: 400 });
// 所有権メンバーシップを作成
await adminClient
.from('memberships')
.insert({
user_id: authData.user!.id,
org_id: org.id,
role: 'owner',
});
return NextResponse.json({ org });
}
サインアップ中はサービスロールクライアントに依存することに注意してください。ユーザーにはメンバーシップがまだないため、RLS は組織作成のために彼らを放り出します。これは典型的なブートストラップの問題の 1 つです。あなたのサービスロールキーが魔法の杖になります。
そして、これ以上ストレスを与えることはできません。サービスロールキーを決してクライアントに公開しないでください。 これは厳密にサーバー側のコードです。
サーバーコンポーネントと RLS: SSR の問題
Next.js 15 のサーバーコンポーネントはサーバー バウンドであり、セキュリティゲームをアップします。ただし、Supabase RLS を使用する場合は問題があります。RLS ポリシーが誰がテーブルにいるかを知るために、ユーザーのセッションを Supabase クライアントに提供する必要があります。
// 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() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// これはサーバーコンポーネント (読み取り専用) で失敗する可能性があります
// ミドルウェアがクッキーのリフレッシュを処理します
}
},
},
}
);
}
// app/[orgSlug]/projects/page.tsx
import { createClient } from '@/lib/supabase/server';
import { headers } from 'next/headers';
export default async function ProjectsPage() {
const supabase = await createClient();
const headersList = await headers();
const tenantSlug = headersList.get('x-tenant-slug');
// スラッグから org ID を取得
const { data: org } = await supabase
.from('organizations')
.select('id')
.eq('slug', tenantSlug)
.single();
if (!org) return <div>Organization not found</div>;
// RLS が自動的にフィルタリングします — 現在のユーザーが
// メンバーシップを持っているプロジェクトのみを返します
const { data: projects } = await supabase
.from('projects')
.select('*')
.eq('org_id', org.id)
.order('created_at', { ascending: false });
return (
<div>
{projects?.map(project => (
<ProjectCard key={project.id} project={project} />
))}
</div>
);
}
ここでのキッカーは、誰かがリクエスト内の org_id を改ざんしても、RLS は動きません。ユーザーがメンバーでない限り、プロジェクトへのアクセスをブロックします。技術的には、.eq('org_id', org.id) はセキュリティのために冗長です。RLS がそれを処理します。しかし、パフォーマンスと読みやすさのために良いです。
パフォーマンス最適化と一般的な落とし穴
N+1 RLS クエリの問題
すべての RLS ポリシーチェックでサブクエリが回転します。10 行のポリシーチェックに接続しているときに 100 行を見ると、100 ラウンドのメンバーシップ検索が意味します。幸いなことに、PostgreSQL はキャッシュするのに十分スマートです。ただし、オーバーヘッドがあります。
修正: STABLE をヘルパー関数に使用します (説明したように)。また、org_id を JWT クレームにデナーマライズすることを検討してください。
-- カスタム JWT フック (Supabase Dashboard > Auth > Hooks)
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event JSONB)
RETURNS JSONB AS $$
DECLARE
org_ids UUID[];
BEGIN
SELECT array_agg(org_id) INTO org_ids
FROM memberships
WHERE user_id = (event->>'user_id')::UUID;
event := jsonb_set(
event,
'{claims,org_ids}',
to_jsonb(org_ids)
);
RETURN event;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
その後、RLS ポリシーになります。
CREATE POLICY "Members can view"
ON projects FOR SELECT
USING (
org_id = ANY(
(SELECT array(SELECT jsonb_array_elements_text(
auth.jwt()->'org_ids'
))::UUID[])
)
);
これは完全にメンバーシップテーブルの検索を圧倒します。org ID は JWT から直接来ます。注意事項: JWT クレームはログイン時に刻印されます。誰かのメンバーシップを変更し、クレームを同期するために再認証が必要になります。通常、これは完全に管理可能です。ドキュメントに記載しておいてください。
接続プーリング
Supabase は PgBouncer を通じて接続プーリングを提供します。Next.js on Vercel で公開する場合は、プーラー URL を API ルートと server components に記憶してください。
# 通常の操作 (プール)
DATABASE_URL=postgres://user:pass@db.project.supabase.co:6543/postgres
# マイグレーション のみ (直接)
DIRECT_URL=postgres://user:pass@db.project.supabase.co:5432/postgres
Supabase の Pro ($25/月) の誰もが、プーラー経由で 200 の同時接続を取得します。ほとんどの SaaS アプリが 1000 ユーザーに同時接続していない場合、十分以上です。
絶対に必要なインデックス
マルチテナント設定のブルートフォース インデックス セットです。
-- EVERY テナント スコープテーブル上
CREATE INDEX idx_{table}_org_id ON {table}(org_id);
-- 一般的なクエリの複合インデックス
CREATE INDEX idx_projects_org_created ON projects(org_id, created_at DESC);
-- メンバーシップ — RLS によって大量にクエリされます
CREATE INDEX idx_memberships_user_org ON memberships(user_id, org_id);
CREATE INDEX idx_memberships_org_role ON memberships(org_id, role);
EXPLAIN ANALYZE — 開発者の親友。RLS を乗せたクエリがどのように動作するかを見てください。正しいインデックスがない場合、プランナーが何をすることを決定するかについて、失礼な目覚めを得るかもしれません。
RLS ポリシーのテスト
誰もがこれをスキップしますが、データ漏洩に対するあなたの最良のセーフティネットです。RLS ポリシーを SQL で直接テストします。
-- 特定のユーザーとしてテスト
SET request.jwt.claims = '{"sub": "user-uuid-here", "role": "authenticated"}';
SET role = 'authenticated';
-- これはユーザーがアクセスできるプロジェクトのみを返すはずです
SELECT * FROM projects;
-- これは失敗するはずです (ユーザーはこの org のメンバーではありません)
INSERT INTO projects (org_id, name) VALUES ('other-org-uuid', 'Sneaky Project');
-- リセット
RESET role;
そして重要なポリシーのための pgTAP を忘れずに。
BEGIN;
SELECT plan(3);
-- テストコンテキストをユーザー A (org 1 のメンバー) として設定
SET LOCAL request.jwt.claims = '{"sub": "user-a-uuid"}';
SET LOCAL role = 'authenticated';
SELECT is(
(SELECT count(*) FROM projects WHERE org_id = 'org-1-uuid')::INTEGER,
5,
'User A sees 5 projects in their org'
);
SELECT is(
(SELECT count(*) FROM projects WHERE org_id = 'org-2-uuid')::INTEGER,
0,
'User A sees 0 projects in other org'
);
SELECT throws_ok(
$$INSERT INTO projects (org_id, name) VALUES ('org-2-uuid', 'Hack')$$,
'new row violates row-level security policy',
'User A cannot insert into other org'
);
SELECT * FROM finish();
ROLLBACK;
CI で実行します。RLS ポリシーをプレイしているすべてのマイグレーションは、完全なテストスイートを激しいワークアウトにさらすべきです。
本番環境デプロイメントチェックリスト
船出する準備はできていますか?これで自分を支えてください。
- テナントデータを抱えている すべての テーブルで RLS が有効
- サービスロールキーはサーバー側に隠しておきます。クライアントの近くはどこでもありません
- すべてのテナント スコープテーブルで
org_idが適切にインデックスされました - メンバーシップヘルパー関数は
SECURITY DEFINERとSTABLEとして定められました - JWT カスタムクレイム ロックアンドロード (JWT ルートの場合)
- クラウドデプロイ用に接続プーリングをフックアップしましたか?
- RLS ポリシーは pgTAP またはその同類で QA をチェックアウトしました
- RLS を実行しているアクティブなクエリに
EXPLAIN ANALYZEを クランクアップしましたか? - 招待/サインアップフローはメンバーシップのブートストラップをあたらしく習得していません
- 認証エンドポイントでのレート制限をオンにしていますか? Supabase は焼き込みオプションを提供します
- Supabase Dashboard (多くの場合はランドマイン) で
authスキーマテーブルの RLS を反転させましたか? - 低速なクエリの埋め込みモニタリング (Supabase Dashboard > Database > Query Performance)
マルチテナント製品を起動し、これらの水に浸ったことがある誰かを希望していますか?私たちの ヘッドレス CMS 開発ソリューション または お問い合わせページ を通じた簡単な質問があなたが必要とするかもしれません。
FAQ
数千のテナントでアプリに Supabase RLS を使用できますか?
絶対に。共有テーブル RLS を 5000 以上のテナントと数百万行でパイロットしました。秘密ソース?org_id 列の適切なインデックス作成と STABLE ヘルパー関数。50,000 以上のテナントまたは数十億行の贅沢を検討していますか?テーブルを org_id でパーティション化したり、スキーマごとのテナント設定を試してみてください。
ユーザーが複数の組織に属する場合、テナント切り替えをどう処理しますか?
クッキーまたは URL (サブドメイン) に保存されたアクティブな組織を保持します。org を入れ替えますか?サブドメイン/クッキーを変更してリフェッチします。アクティブな org を JWT にタックしないでください。クッキーミドルウェアがのぞくことができる方法は切り替え方法です。
テーブルで RLS を有効にするのを忘れた場合はどうなりますか?
すべての認証されたユーザーがすべての行をタップできます。それは PostgreSQL のデフォルトスタンス — RLS なしのテーブルに行レベルの拘束はありません。Supabase Dashboard は RLS を欠いているテーブルにフラグを立てますが、pg_tables と pg_policies へのクエリを使用して CI に埋め込みます。
Supabase のサービスロールキーまたは管理者務用のカスタム PostgreSQL ロールを使用する必要がありますか?
ほとんど、サービスロールキーで十分です。完全に RLS をスキップするため、サーバー側の使用専用です。細かいガバナンスが必要ですか (すべての org に潜んでいるが削除から恥ずかしい「管理者」ロールのような)?それはカスタム PostgreSQL territory — 高度で、複雑な内部ツールが要求されるまで典型的にレーダーから外れています。
RLS ポリシーにトリップすることなくデータベースマイグレーションをどのように実行しますか?
Supabase の CLI (supabase db push または supabase migration) と同様にダイレクトデータベース URL (プール をスキップ) があなたの背中を持っています。RLS ポリシー編集をスキーマ調整と同じマイグレーションに一緒に置きます。マイグレーションをステージングプロジェクトに対してテストキャスト — Supabase は Pro でプレビューブランチを回転させることができます。
RLS ポリシーは他の API またはサービスのデータに到達できますか?
いいえ。RLS ポリシーは SQL に居心地よく座り、PostgreSQL によって評価されます。外部データをチェックしたいですか (機能フラグサービスのような)?そのデータをデータベーステーブルにセメントし、ポリシーで参照します。典型的なパターンは Stripe から organizations.plan 列に購読ステータスを同期することです。
アプリケーション層でフィルタリングと比較して、RLS のパフォーマンス税はいくらですか?
Supabase Pro ベンチマークに (2 vCPU、8GB RAM)、RLS は、正しいインデックスを備えた基本的なメンバーシップチェックポリシーに対して、1 クエリあたり追加の 1 ~ 3ms をスプレッドします。ポリシーの複雑性またはジョインで野生的に進み、5 ~ 15ms を追加するかもしれません。JWT クレーム戦術 (トークンに org_ids を保存) は、サブクエリダンスがないため 1ms 未満にスライスします。典型的な Web アプリの場合、レイテンシーのしずくはごくわずかです。
これはどのように Supabase Realtime サブスクリプションで動作しますか?
Supabase Realtime は RLS 規則に従うことで再生します。テーブルの変更をチューニングして、RLS に従ってアクセスできる行のイベントのみをキャッチします。これは追加のいじくり回しなしで既製品で展開されます。ユーザーセッションを持つクライアント側の Supabase を確認してください。@supabase/ssr はシームレスに処理します。