「Supabaseは速く作れる」——これは本当です。けれど、速く作れることと本番運用に耐えることは別物です。匿名キーをそのままクライアントに配り、RLSを書かずにテーブルを公開し、サーバーで getSession() を信用してしまう——こうした"とりあえず動く"実装は、ローンチ翌日のインシデントになって返ってきます。
この記事は、Supabaseを本番で安全に・速く・保守できる形で使い切るための実践ガイドです。前半で「いつ選ぶべきか」という設計判断を、後半で認証・RLS・Realtime・Edge Functions・Storage・Vectorの実コードを扱います。
この記事のルール:一次情報は公式ドキュメント。 本文中のAPI・SQL・推奨事項はすべて supabase.com/docs を 2026-06-24時点で確認した内容に基づきます。Supabaseは更新が速いプロダクトです。API名や推奨は変わり得るため、各節末に一次情報のURLを併記しました。実装前に必ず最新版を確認してください。記事の役割は、公式ドキュメントをより速く・正しく咀嚼するための地図を提供することです。
0. Supabaseとは何か(公式の定義)
公式は Supabase を 「open source Firebase alternative」、すなわちPostgresを中核に据えたバックエンド開発ツールキットと位置づけています。重要なのは、これが"Postgresの抽象化"ではなく生のPostgresそのものだという点です。公式の言葉を借りれば「Every Supabase project is a full Postgres database」。逃げ場がない独自DBではなく、いつでも pg_dump して持ち出せる標準のPostgresが土台にあります。
Supabaseは、そのPostgresの周りにオープンソースのコンポーネントを束ねた構成になっています(architecture)。
| コンポーネント | 役割 | 実体 |
|---|---|---|
| Postgres | データベース本体 | PostgreSQL |
| Studio | 管理ダッシュボード | OSS |
| Auth (GoTrue) | ユーザー管理・JWT発行 | GoTrue |
| Data API | テーブルを自動でREST化 | PostgREST |
| Realtime | WebSocket(変更購読・Presence・Broadcast) | Realtime Server |
| Storage | S3互換のオブジェクトストレージ | Storage API |
| Edge Functions | サーバーレス関数(TypeScript) | Supabase Edge Runtime(Deno互換) |
| Supavisor | コネクションプーラ | Supavisor |
| Kong | APIゲートウェイ | Kong (NGINX) |
そして AI 用途では pgvector 拡張により「すでに持っているDBがそのままベクトルDBになる(The best vector database is the database you already have)」という設計思想を打ち出しています(guides/ai)。
1. いつSupabaseを選ぶべきか(適材適所の判断)
技術選定で最も多い失敗は「流行っているから」です。私の実務上の判断基準を、トレードオフとともに明示します。
向いているケース
- リレーショナルなデータモデルが中心。トランザクション、外部キー、JOIN、制約をDBに寄せたい。
- 認証・ストレージ・リアルタイムをワンストップで揃えたい。個別にAuth0 + S3 + WebSocketサーバーを組むより、初期の認知負荷とコストが圧倒的に低い。
- 少人数で速く立ち上げたい。一人〜小規模チームのMVP〜中規模SaaS。
- クライアント(Web/モバイル)から直接DBを叩きたい。PostgRESTとRLSの組み合わせが効く領域。
- 将来の移行可能性を残したい。生のPostgresなので、ロックインが浅い。
慎重に検討すべきケース
- 超高頻度の書き込み × 全クライアント配信が主役のアプリ。Realtimeの設計を誤るとコストとスケールで詰まる(後述の「Broadcast vs Postgres Changes」を参照)。
- 複雑なバックエンドのドメインロジックの塊。Edge Functionsは軽量なAPI・Webhook向き。重い業務ロジックの本体は、別のアプリ層(Next.jsのRoute Handlers / 専用APIサーバー)に置くべき場面が多い。
- 既に巨大なPostgres運用ノウハウを持つ組織。マネージドの制約より自前運用の自由度が勝つこともある。
判断のコツ:Supabaseは「Postgresを中心に据えた設計が正しいか」を問うリトマス試験紙です。データの真実をDBに寄せたいなら強力に効きます。逆に、DBを単なる永続化層としか見ない設計なら、Supabaseの旨味(RLS・PostgREST・Realtime authorization)の多くを捨てることになります。
2. 認証 × Next.js 16(App Router)— @supabase/ssr の正しい作法
ここが最も事故の多い領域です。2026年時点の正解を、公式に忠実に固めます。
2.1 前提:使うパッケージと環境変数
- 認証クライアントは
@supabase/ssr(@supabase/supabase-jsと併用)。 - 旧
@supabase/auth-helpers-nextjsは非推奨です。新規実装で使ってはいけません(移行ガイド)。 - 環境変数は現行の publishable key 命名を推奨(旧
ANON_KEYも後方互換で動作します)。
# .env.local
NEXT_PUBLIC_SUPABASE_URL=<your-project-url>
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<your-publishable-key>
npm install @supabase/supabase-js @supabase/ssr
2.2 鉄則:Cookieは getAll / setAll だけを使う
公式ドキュメントの最重要警告を、そのまま受け取ってください。
getAllとsetAllだけを使うこと。get/set/removeは絶対に使わないこと。
get / set / remove は非推奨であり、正しく実装するのが難しく、エッジケースに対応できません。これを破ると「ランダムなログアウト」「セッションの早期切断」「状態の不整合」という、再現しにくく厄介なバグを生みます(creating-a-client)。
2.3 ブラウザ用クライアント
// lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";
export const createClient = () =>
createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
);
2.4 サーバー用クライアント(Next.js 16:cookies() は await)
Next.js 15/16では cookies() が非同期です。createClient を async にして await cookies() する形が正解です。
// 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_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
);
} catch {
// Server Component から setAll が呼ばれたケース。
// ミドルウェアでセッションを更新しているなら無視してよい。
}
},
},
},
);
}
try/catch の意図は明確です:Server ComponentはレスポンスのCookieを書き換えられないため、ここでの書き込み失敗は想定内であり、実際のCookie更新は次のミドルウェアが担います。
2.5 ミドルウェア:全リクエストでセッションを更新する
Server Componentがトークンを更新(書き込み)できない以上、ミドルウェアがリクエストごとにトークンをリフレッシュする役割を負います。これを省くと、アクセストークンが切れた瞬間に静かにログアウトします。
// lib/supabase/middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value),
);
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options),
);
},
},
},
);
// 重要:ここでセッション(トークン)をリフレッシュする
const {
data: { user },
} = await supabase.auth.getUser();
// 未認証ユーザーを保護ページから弾くリダイレクトはここに書く
if (!user && request.nextUrl.pathname.startsWith("/app")) {
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
return supabaseResponse;
}
// middleware.ts
import { type NextRequest } from "next/server";
import { updateSession } from "@/lib/supabase/middleware";
export async function middleware(request: NextRequest) {
return await updateSession(request);
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.svg$).*)"],
};
2.6 サーバーで「誰か」を判断する3つのAPIの使い分け
ここが認証の核心です。3つの選択肢の挙動と信頼性はまったく違います。
| API | 何をするか | サーバーで信頼してよいか |
|---|---|---|
getSession() | 保存済みのセッションを読むだけ | ❌ 信頼してはいけない。トークンの再検証を保証しない |
getUser() | Auth サーバーへネットワーク要求して本人確認 | ✅ 真正。認可の根拠にしてよい |
getClaims() | JWTをJWKSで検証してクレームを取り出す | ✅ 推奨。非対称鍵ならローカル検証で高速 |
公式は明確です。
サーバーコードの中で
getSession()を信頼してはいけない。 トークンの再検証を保証しないため、認可の根拠にしてはならない。生のトークン(access/refresh/期限)を読むときだけ使うこと。
一方 getClaims() は新しい推奨で、JWTをJWKSエンドポイントで検証してからクレームを返します。プロジェクトが非対称署名鍵(ES256/RS256)を使っていればローカル検証で完結し(多くの場合キャッシュも効くため getUser() より大幅に速い)、対称鍵(HS256)の場合はAuthサーバーへの問い合わせになります。
// Server Component / Route Handler での認可
import { createClient } from "@/lib/supabase/server";
export default async function DashboardPage() {
const supabase = await createClient();
// 推奨:getClaims()(非対称鍵ならローカル検証で高速)
const { data, error } = await supabase.auth.getClaims();
if (error || !data) redirect("/login");
const userId = data.claims.sub;
// …userId を根拠に認可・データ取得
}
使い分けの結論
- ページ/データの保護(認可判断):
getClaims()を第一選択。非対称鍵未設定でもgetUser()で代替可。- ミドルウェアのトークン更新:
getUser()(リフレッシュを強制する)。- 生トークンが必要なときだけ:
getSession()。認可には使わない。
2.7 JWT署名鍵:対称鍵から非対称鍵へ(2025〜GA)
getClaims() の高速ローカル検証を可能にするのが JWT Signing Keys です。従来の「全JWTを1つの共有シークレットで署名する」方式は非推奨になりました(後方互換のため残存)。
- ES256(NIST P-256楕円曲線)=推奨。RSAより速く、署名が短い=Cookieが小さくなる。
- RS256(RSA 2048):広く対応するが遅め。公式はP-256を推奨。
- EdDSA(Ed25519):「Coming soon」。
- HS256(共有シークレット):本番非推奨。
非対称鍵を有効化すると、JWTの検証はAuthサーバーを介さずに行え、公開鍵は JWKS エンドポイント https://<project>.supabase.co/auth/v1/.well-known/jwks.json から取得できます。鍵のローテーションは「standby → current → previously-used → revoke」の流れで、ユーザーを強制ログアウトさせずゼロダウンタイムで回せます。
実務的な意味:非対称鍵 +
getClaims()は、RLSを多用するアプリのサーバー側認可コストを劇的に下げます。新規プロジェクトでは最初からES256を有効化しておくのが定石です。
一次情報: Server-side Auth (Next.js) / Creating a client / getClaims / JWT Signing Keys
3. Row Level Security(RLS)— DBに認可を寄せる
PostgRESTでテーブルをクライアントに公開する以上、認可はDBの行レベルで強制するのが大原則です。公式の言葉どおり「RLSは、クライアントからDBを直接クエリしても安全にするための仕組み」です。
RLSの「なぜ」と、オフライン同時編集という極限ケースでの設計判断は、別記事「クライアントを信じない設計:オフライン同時編集アプリで整合性と認可をPostgreSQLに寄せる」で実プロダクトを題材に深掘りしています。本節は公式準拠のリファレンスとして、正しい書き方と性能を体系化します。
3.1 有効化とポリシーの形
-- まずテーブルでRLSを有効化(これを忘れると全公開)
alter table public.todos enable row level security;
ポリシーの基本形は次のとおりです。
create policy "<ポリシー名>"
on <テーブル>
for <select | insert | update | delete | all>
to <ロール> -- 例: authenticated, anon
using (<可視性・対象の条件>) -- SELECT/UPDATE/DELETE:既存行のフィルタ
with check (<書き込み値の検証>); -- INSERT/UPDATE:新しい行の値の検証
操作ごとの実例(公式の現行スタイルに忠実):
-- SELECT:自分のtodoだけ見える
create policy "Individuals can view their own todos."
on public.todos for select
using ( (select auth.uid()) = user_id );
-- INSERT:自分名義でだけ作成できる
create policy "Users can create a profile."
on public.profiles for insert
to authenticated
with check ( (select auth.uid()) = user_id );
-- UPDATE:using と with check の両方が要る
create policy "Users can update their own profile."
on public.profiles for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
-- DELETE
create policy "Users can delete their own profile."
on public.profiles for delete
to authenticated
using ( (select auth.uid()) = user_id );
ポイントは2つ。
usingは「どの既存行に触れてよいか」(SELECT/UPDATE/DELETE)、with checkは「書き込む値が正しいか」(INSERT/UPDATE)。役割が違うので、UPDATEでは両方を書きます。- 公式の例が最初から
(select auth.uid())とサブクエリで包んでいるのは偶然ではありません。これが後述の性能最適化のデフォルトスタイルです。
3.2 ポリシー内で使える認証ヘルパー
auth.uid()… リクエスト中ユーザーのID(JWTのsub)。auth.jwt()… JWT全体をjsonbで返す。auth.jwt() -> 'app_metadata'のように辿れる。- 認可に使うデータは
user_metadataではなくapp_metadataに置くこと。user_metadataはユーザー自身が書き換え可能なため、認可の根拠にすると権限昇格を許します。
- 認可に使うデータは
- MFA強制(AAL2)は restrictive ポリシーで:
create policy "Restrict updates to MFA users."
on public.profiles
as restrictive
for update
to authenticated
using ( (select auth.jwt()->>'aal') = 'aal2' );
3.3 RLSの性能最適化(ここで差がつく)
RLSは行ごとに評価されるため、書き方次第で桁違いに遅くなります。公式が挙げる最適化と、ドキュメントが示す自社ベンチマーク値(テスト条件下での参考値であり、保証値ではありません)を整理します。
| 最適化 | やり方 | 公式ベンチの改善幅(参考値) |
|---|---|---|
| ①インデックス | ポリシーで使う列に索引を張る | 最大 約99.94% |
| ②関数をサブクエリで包む | auth.uid() → (select auth.uid()) で文ごとに1回評価 | 約94.97〜99.99% |
| ③明示フィルタ | クライアント側でも .eq('user_id', userId) を付ける | 約 94.74% |
④TO でロール指定 | to authenticated 等で無関係ロールを早期スキップ | 約 99.78% |
⑤security definer 関数 | 認可テーブルの参照を関数に逃がしRLS再評価を回避 | 約 99.78% |
| ⑥JOINを減らす | 認可テーブルとのJOINより、許可IDの集合を in/any で判定 | — |
-- ①インデックス
create index idx_todos_user_id on public.todos using btree (user_id);
②の理屈は「(select ...) で包むと初期プラン(initPlan)にキャッシュされ、行ごとではなく文ごとに1回しか評価されない」というものです。RLSを書くときの最も費用対効果が高い一手です。
数字の扱い方:上記%は公式が特定のテストテーブルで計測した値です。「Supabaseのベンチマークでは最大〜%改善」という文脈で受け取り、自分のスキーマでは必ず
explain analyzeで確認してください。捏造された万能薬はありません。
3.4 security definer 関数の正しい書き方
認可テーブルの参照を関数に逃がす(⑤)ときは、security definer を使います。そのとき必ず search_path を固定してください。これを怠るとスキーマ汚染による権限昇格の入口になります。
create function public.hello_world()
returns text
language plpgsql
security definer set search_path = '' -- 必須。空にしたら関数内は public.table と明示する
as $$
begin
return 'hello world';
end;
$$;
公式の指針は「まずは security invoker(デフォルト)を使う。security definer を使うなら search_path の設定は必須」です。
3.5 RBAC:Custom Access Token Hook でロールをJWTに載せる
「管理者」「モデレーター」といったロールベース認可は、Custom Access Token Hook でロールをJWTクレームに注入し、ポリシー側は auth.jwt() を読むだけにするのが公式パターンです。毎回テーブルをJOINするより速く、宣言的です。
-- 1) ロールと権限の型・テーブル
create type public.app_permission as enum ('channels.delete', 'messages.delete');
create type public.app_role as enum ('admin', 'moderator');
create table public.user_roles (
id bigint generated by default as identity primary key,
user_id uuid references auth.users on delete cascade not null,
role app_role not null,
unique (user_id, role)
);
-- 2) トークン発行前に user_role をクレームへ注入するフック
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable
as $$
declare
claims jsonb;
user_role public.app_role;
begin
select role into user_role from public.user_roles
where user_id = (event->>'user_id')::uuid;
claims := event->'claims';
if user_role is not null then
claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
else
claims := jsonb_set(claims, '{user_role}', 'null');
end if;
event := jsonb_set(event, '{claims}', claims);
return event;
end;
$$;
-- 3) フックは supabase_auth_admin として実行される。権限を厳格に絞る
grant usage on schema public to supabase_auth_admin;
grant execute on function public.custom_access_token_hook to supabase_auth_admin;
revoke execute on function public.custom_access_token_hook from authenticated, anon, public;
grant all on table public.user_roles to supabase_auth_admin;
revoke all on table public.user_roles from authenticated, anon, public;
create policy "Allow auth admin to read user roles" on public.user_roles
as permissive for select to supabase_auth_admin using (true);
そしてポリシーからは authorize() ヘルパー越しに権限を判定します。
create or replace function public.authorize(requested_permission app_permission)
returns boolean
language plpgsql
stable
security definer set search_path = ''
as $$
declare
bind_permissions int;
user_role public.app_role;
begin
select (auth.jwt() ->> 'user_role')::public.app_role into user_role;
select count(*) into bind_permissions
from public.role_permissions
where permission = requested_permission and role = user_role;
return bind_permissions > 0;
end;
$$;
create policy "Allow authorized delete" on public.channels
for delete to authenticated
using ( (select authorize('channels.delete')) );
フックはダッシュボード(Authentication → Hooks)または config.toml で有効化が必要です。
一次情報: Row Level Security / Database Functions / RBAC(Custom Claims)
4. Realtime — Broadcast・Presence・Postgres Changes
Realtimeには3つの機能があります(realtime)。
- Broadcast … クライアント間の低遅延メッセージ。チャット、カーソル追跡、ゲームイベント、通知。
- Presence … 「誰がオンラインか」の状態同期。
- Postgres Changes … DBの変更(INSERT/UPDATE/DELETE)の購読。
4.1 重要な方針転換:規模が出るなら Postgres Changes より Broadcast
直感的には「DB変更を購読(Postgres Changes)」が王道に見えます。しかし現在の公式推奨は、スケールとセキュリティのために Broadcast(特に "Broadcast from Database")を使うことです。
公式:「ほとんどのユースケースで Broadcast を推奨します」「Postgres Changes は規模が大きくなると制約があります」。
理由は構造的です。Postgres Changesは全WAL変更を単一のレプリケーションスロット経由で流し、クライアントごとにRLSを評価するため、接続数が増えるとボトルネックになります。Broadcast from Databaseは、DBトリガーから realtime.broadcast_changes() でトピックに直接ファンアウトするため、はるかにスケールします。
4.2 Broadcast の基本(クライアント)
import { createClient } from "@supabase/supabase-js";
const supabase = createClient("<url>", "<publishable-key>");
const channel = supabase.channel("room-1");
// 受信
channel
.on("broadcast", { event: "shout" }, (payload) => console.log(payload))
.subscribe((status) => {
if (status !== "SUBSCRIBED") return;
// 送信(subscribe後はWebSocket経由)
channel.send({ type: "broadcast", event: "shout", payload: { message: "Hi" } });
});
broadcast: { self: true } で自分の送信も受信、broadcast: { ack: true } でサーバーACKを得られます。
4.3 Broadcast from Database(推奨構成)
DB変更をスケーラブルに配信する現行の正攻法です。トリガーでブロードキャストし、クライアントはプライベートチャネルで受けます。
create or replace function public.your_table_changes()
returns trigger
security definer
language plpgsql
as $$
begin
perform realtime.broadcast_changes(
'topic:' || coalesce(NEW.id, OLD.id)::text, -- トピック
TG_OP, -- event
TG_OP, -- operation
TG_TABLE_NAME,
TG_TABLE_SCHEMA,
NEW,
OLD
);
return null;
end;
$$;
create trigger handle_your_table_changes
after insert or update or delete on public.your_table
for each row execute function public.your_table_changes();
const gameId = "abc";
await supabase.realtime.setAuth(); // Realtimeの認可トークンを更新(プライベートチャネルに必須)
const channel = supabase
.channel(`topic:${gameId}`, { config: { private: true } })
.on("broadcast", { event: "INSERT" }, (p) => console.log(p))
.on("broadcast", { event: "UPDATE" }, (p) => console.log(p))
.on("broadcast", { event: "DELETE" }, (p) => console.log(p))
.subscribe();
4.4 Realtime Authorization(realtime.messages のRLS)
プライベートチャネルのアクセス可否は、realtime.messages テーブルのRLSポリシーで制御します。接続時にJWT・トピック・拡張種別(broadcast/presence)を見て評価されます。
-- 例:そのルームのメンバーだけ受信を許可
create policy "Members can receive room broadcasts"
on "realtime"."messages"
for select
to authenticated
using (
exists (
select 1 from public.rooms_users
where user_id = (select auth.uid())
and room_topic = (select realtime.topic())
and realtime.messages.extension in ('broadcast', 'presence')
)
);
アクセスポリシーは接続時に評価され、接続中はキャッシュされます。複雑なRLSは接続レイテンシに直結するため、シンプルに保ってください。
4.5 Presence(オンライン状態)
const room = supabase.channel("room-1");
room
.on("presence", { event: "sync" }, () => console.log(room.presenceState()))
.on("presence", { event: "join" }, ({ key, newPresences }) => console.log("join", key))
.on("presence", { event: "leave" }, ({ key, leftPresences }) => console.log("leave", key))
.subscribe(async (status) => {
if (status !== "SUBSCRIBED") return;
await room.track({ user: "user-1", online_at: new Date().toISOString() });
});
Presenceは状態同期のコストが高い機能です。「同時接続が多いと負荷が大きい」点を踏まえ、本当に必要な画面だけで使ってください。
一次情報: Realtime / Broadcast / Subscribing to Database Changes / Realtime Authorization
5. Edge Functions — withSupabase(2026年の新しい標準)
Edge FunctionsはTypeScriptファーストのサーバーレス実行環境(Supabase Edge Runtime, Deno互換)です。2025〜2026年で書き方が変わりました。
5.1 現在の推奨:Deno.serve ではなく withSupabase
現行のクイックスタートは、npm:@supabase/server の withSupabase で fetch ハンドラを包む形を標準としています。返り値が標準の (Request) => Promise<Response> なので、Vercel Functions / Cloudflare Workers / Bun などへ移植しやすいのが利点です。
import { withSupabase } from "npm:@supabase/server";
export default {
fetch: withSupabase({ auth: ["publishable", "secret"] }, async (req, ctx) => {
const { name } = await req.json();
return Response.json({ message: `Hello ${name}!` });
}),
};
Deno.serveも引き続きサポートされますが、公式の現在のベストプラクティスはwithSupabaseでfetchをエクスポートする形です。
5.2 認証済みユーザーにスコープしたクライアント
auth: 'user' を指定すると、ctx からRLSスコープ済みクライアントとユーザークレームを直接受け取れます。自前でAuthorizationヘッダを読み回す必要はありません。
import { withSupabase } from "npm:@supabase/server";
export default {
fetch: withSupabase({ auth: "user" }, async (_req, ctx) => {
const { supabase, supabaseAdmin, userClaims, jwtClaims, authMode } = ctx;
// supabase … 認証ユーザーにRLSスコープされたクライアント
// supabaseAdmin … RLSをバイパス(service role)。取り扱い注意
// userClaims … JWTから得たユーザー識別(id, email, role)
return Response.json({ email: ctx.userClaims?.email });
}),
};
supabaseAdmin(service role)はRLSを完全にバイパスします。クライアントへ漏れたら全権限を渡すのと同じです。Edge Functionの中だけで使い、決してレスポンスやログに混ぜないでください。
5.3 CLIと運用
supabase functions new hello-world # 雛形生成
supabase start # ローカルスタック(Docker)
supabase functions serve hello-world # ローカル実行
supabase functions deploy hello-world # デプロイ(名前省略で全関数)
# supabase functions deploy --use-api # Docker不要のAPI経由デプロイ
実運用で効く機能:
- バックグラウンドタスク:
EdgeRuntime.waitUntil(promise)でレスポンス後も処理を継続(background-tasks)。 - リージョン指定呼び出し:
supabase.functions.invoke('fn', { region: FunctionRegion.UsEast1 })。DBに近いリージョンで実行してレイテンシを削る(regional-invocation)。 - ランタイムは Deno 2.1 に対応。
5.4 制限(設計時に必ず把握する)
| 項目 | Free | Paid |
|---|---|---|
| 実行時間(wall-clock) | 150秒 | 400秒 |
| CPU時間/リクエスト | 2秒(実CPU、非同期I/O除く) | 同左 |
| メモリ | 256MB | 256MB |
設計の含意:CPU 2秒・メモリ256MBという制約は、Edge Functionsが重い計算の本体ではなく、軽量なAPI・Webhook・オーケストレーション向きであることを示します。重い処理は分割し、長時間処理はバックグラウンドタスクやキュー(後述)に逃がしてください。
一次情報: Edge Functions / Quickstart / Auth in Functions / Limits
6. Storage — アップロードと署名URL、そしてRLS
// アップロード(upsertやcontentTypeも指定可)
await supabase.storage.from("avatars").upload("public/avatar1.png", file, {
upsert: true,
contentType: "image/png",
});
// 公開バケットのURL
const { data } = supabase.storage.from("avatars").getPublicUrl("public/avatar1.png");
// 非公開ファイルの時間制限つき署名URL(秒指定)
const { data: signed } = await supabase.storage
.from("private-docs")
.createSignedUrl("contract.pdf", 3600); // 1時間
そしてストレージの認可もRLSです。ファイルのメタデータは storage.objects テーブルにあり、ここにポリシーを書きます。storage.foldername(name) でパスを配列(1始まり)に分解できるのが定石です。
-- 各ユーザーは「自分のIDフォルダ」にだけアップロードできる
create policy "Users upload to their own folder"
on storage.objects for insert to authenticated
with check (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);
-- 自分が所有するオブジェクトだけ閲覧できる
create policy "Owner can read"
on storage.objects for select to authenticated
using ( (select auth.jwt()->>'sub') = owner_id );
落とし穴:
upsert: trueを使うなら、INSERTだけでなくSELECTとUPDATEのポリシーも必要です。「アップロードできるのに上書きで失敗する」の典型原因がこれです。
一次情報: Storage / Storage Access Control
7. AI / Vector(pgvector)— 既存DBがそのままベクトルDBになる
別途ベクトル専用DBを建てなくても、Postgresの pgvector 拡張でセマンティック検索が組めます。
-- 1) 拡張を有効化(extensions スキーマに)
create extension vector with schema extensions;
-- 2) 埋め込み列(次元数はモデルの出力に一致させる)
create table documents (
id serial primary key,
title text not null,
body text not null,
embedding extensions.vector(384)
);
距離演算子は3つ。正しく選ぶことが精度に直結します。
| 演算子 | 意味 | インデックス演算子クラス |
|---|---|---|
<-> | ユークリッド距離(L2) | vector_l2_ops |
<#> | 負の内積(negative inner product) | vector_ip_ops |
<=> | コサイン距離 | vector_cosine_ops |
<#>は「内積」ではなく負の内積です(Postgresの索引は昇順しか扱えないため、pgvectorが符号を反転している)。OpenAIなど正規化済み埋め込みなら<#>が高速、正規化の有無が不明なら<=>(コサイン)が安全なデフォルト、というのが公式の指針です。
セマンティック検索のRPC(match_documents):
create or replace function match_documents (
query_embedding extensions.vector(384),
match_threshold float,
match_count int
)
returns table (id bigint, title text, body text, similarity float)
language sql stable
as $$
select
documents.id, documents.title, documents.body,
1 - (documents.embedding <=> query_embedding) as similarity
from documents
where 1 - (documents.embedding <=> query_embedding) > match_threshold
order by documents.embedding <=> query_embedding asc
limit match_count;
$$;
const { data } = await supabase.rpc("match_documents", {
query_embedding: embedding, // クエリ文を埋め込んだベクトル
match_threshold: 0.78,
match_count: 10,
});
インデックスは HNSW が現行の第一推奨です(「性能とデータ変化への頑健性からHNSWを推奨」)。IVFFlatはデータ分布が変わると再構築が必要になるためです。
create index on documents using hnsw (embedding vector_cosine_ops);
-- 2,000次元超は halfvec へキャストして索引化
create index on documents using hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops);
自動埋め込み(Automatic Embeddings)
コンテンツ更新時に埋め込みを自動同期する仕組みも公式に用意されています。pgmq(ジョブキュー)+ pg_net(DBからの非同期HTTP)+ pg_cron(定期処理)+ Edge Functions(埋め込みAPI呼び出し)+ Vault(URLの安全保管)の組み合わせで、「行が変わったらキューに積み、Cronがバッチで埋め込み生成・更新、失敗は再試行」という流れを実現します(automatic-embeddings)。
一次情報: AI & Vectors / Vector Columns / Semantic Search / Vector Indexes
8. 開発・運用の足回り(マイグレーション/ブランチ/Cron/Queues)
8.1 宣言的スキーマとマイグレーション
スキーマの理想形を supabase/schemas/*.sql に書き、差分からマイグレーションを生成する「宣言的スキーマ」と、従来のマイグレーションを併用できます。
supabase migration new create_employees_table # 手書きマイグレーション
supabase db diff -f create_cities_table # 宣言的スキーマから差分生成
supabase migration up # ローカル適用
supabase db push # リモートへ反映
重要な制約:宣言的スキーマの差分ツールは、DML(insert/update/delete)・RLSポリシーの変更・マテビュー・コメント・パーティションなどを捕捉しません。これらは手書きマイグレーションで管理してください。「RLSポリシーをdiffに任せて取りこぼす」のは事故の定番です。
運用原則は「各開発者は自分のブランチでマイグレーションを作り、リモートDBを直接触らない」「db push は同時に一人だけ(タイムスタンプ順に適用されるため衝突する)」。
8.2 ブランチング・リードレプリカ・Cron・Queues
- Database Branching:本番から分岐した隔離環境。Preview Branch(PRに紐づき自動破棄)と Persistent Branch(staging等)。新ブランチには本番データは入りません(本番データ保護)。GitHub連携で「
mainへpush → 本番反映」。 - Read Replicas:本番と非同期同期される追加DB。RESTのGETのみ対応で、地理ルーティングで近いレプリカへ読みを流し、書き込みはプライマリへ。
- Supabase Cron:
pg_cronベースの定期実行。SQL/DB関数/HTTP/Edge Functionを叩ける。 - Supabase Queues:
pgmqベースのPostgresネイティブな耐久キュー。可視性ウィンドウ内での配信、メッセージのアーカイブ、RLSによる認可。Edge Functionsの「重い処理を逃がす」先として有効。
料金について:プランは Free($0)/ Pro($25〜)/ Team($599〜)/ Enterprise(カスタム)という構成ですが、各上限値や従量単価、レプリカ/ブランチの提供条件は改定されます。金額は必ず公式の料金ページで最新を確認してください(本稿では確定値の断定を避けます)。
一次情報: Declarative Schemas / Migrations / Branching / Read Replicas / Cron / Queues
9. 本番で踏み抜きやすい落とし穴(チェックリスト)
設計レビューでそのまま使える形でまとめます。
- サーバーで
getSession()を認可に使っていないか →getClaims()(推奨)かgetUser()を使う。 - Cookieで
get/set/removeを使っていないか →getAll/setAllのみ。 - ミドルウェアでセッションを更新しているか → 未実装だと静かにログアウトする。
- 全テーブルでRLSを有効化したか →
enable row level securityの付け忘れ=全公開。 - ポリシー関数を
(select auth.uid())で包んだか → 行ごと評価を文ごと評価に。 - ポリシーに
to authenticated等のロールを指定したか。 - 認可データを
app_metadataに置いたか(user_metadataは改ざん可能)。 -
security definer関数にsearch_path = ''を設定したか。 - service role キー(
supabaseAdmin)がクライアント/ログに漏れていないか。 - 高頻度のDB変更配信を Postgres Changes で全公開していないか → Broadcast from Database を検討。
- Storageの
upsertに SELECT/UPDATE ポリシーを付けたか。 - 宣言的スキーマのdiffに RLS変更/DMLを任せていないか → 手書きマイグレーションへ。
- 新規プロジェクトでES256(非対称JWT署名鍵)を有効化したか。
- Edge Functionに重い計算を載せていないか(CPU 2秒・256MB)。
まとめ:Supabaseは「設計をDBに寄せる」と本当に強い
Supabaseの価値は「速く作れる」だけではありません。RLSで認可をDBに寄せ、Realtime AuthorizationとPostgRESTで信頼境界を一貫させ、pgvectorでAIまで同じDBで完結させる——この「Postgres中心設計」を正しく踏めたとき、少人数でもエンタープライズ級の堅牢さに届きます。逆に、ここで挙げた落とし穴を踏むと、速さは技術的負債に化けます。
私は、こうした「派手さの裏にある地味で致命的な設計判断」を一つずつ潰しながら、一人 × 生成AI(Claude Code)で、速く・安く・安全に本番プロダクトを作り、運用しています。Supabaseを採用した(あるいは採用を検討している)プロダクトで、認証・RLS・Realtimeの設計に不安があれば、設計レビューから実装・運用まで伴走できます。
本稿のAPI・推奨は2026-06-24時点の公式ドキュメントに基づきます。Supabaseは更新が速いため、実装前に各節の一次情報リンクで最新を確認することを強くおすすめします。