# 【2026年版】Supabase本番運用ガイド：公式ドキュメント準拠でNext.js × RLS × Realtime × Edge Functionsを実装する

> Supabaseを「とりあえず動く」から「本番に耐える」へ。公式ドキュメント（2026-06-24時点）に忠実に、@supabase/ssrによるNext.js 16認証、RLSの正しい書き方と性能最適化、getClaimsとJWT署名鍵、Realtime Broadcast、Edge Functions（withSupabase）、Storage、pgvectorまでを実コードと判断基準つきで体系化した実践ガイドです。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: Supabase, PostgreSQL, RLS, Next.js, TypeScript, リアルタイム, アーキテクチャ設計
- URL: https://tomodahinata.com/blog/supabase-production-guide-nextjs-rls-realtime-edge-functions

## 要点

- Supabase は生の Postgres が土台でロックインが浅く、RLS で認可を DB に寄せる『Postgres 中心設計』が前提のときに最も強い
- @supabase/ssr 認証は Cookie を getAll/setAll のみで扱い、ミドルウェアでセッションを更新する。get/set/remove は使わない
- サーバーの認可判断は getClaims() が第一選択（非対称鍵 ES256 ならローカル検証で高速）。getSession() は認可に使わない
- RLS は (select auth.uid()) でのサブクエリ包み・索引・to ロール指定で桁違いに速くなり、認可データは app_metadata に置く
- Realtime は規模が出るなら Postgres Changes より Broadcast、Edge Functions は CPU 2秒制約ゆえ軽量な API・Webhook 向き

---

「Supabaseは速く作れる」——これは本当です。けれど、速く作れることと**本番運用に耐えること**は別物です。匿名キーをそのままクライアントに配り、RLSを書かずにテーブルを公開し、サーバーで `getSession()` を信用してしまう——こうした"とりあえず動く"実装は、ローンチ翌日のインシデントになって返ってきます。

この記事は、Supabaseを**本番で安全に・速く・保守できる形で使い切る**ための実践ガイドです。前半で「いつ選ぶべきか」という設計判断を、後半で認証・RLS・Realtime・Edge Functions・Storage・Vectorの**実コード**を扱います。

> **この記事のルール：一次情報は公式ドキュメント。**
> 本文中のAPI・SQL・推奨事項はすべて [supabase.com/docs](https://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](https://supabase.com/docs/guides/getting-started/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](https://supabase.com/docs/guides/ai)）。

> 一次情報: [What is Supabase / Architecture](https://supabase.com/docs/guides/getting-started/architecture)

---

## 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` は非推奨**です。新規実装で使ってはいけません（[移行ガイド](https://supabase.com/docs/guides/troubleshooting/how-to-migrate-from-supabase-auth-helpers-to-ssr-package-5NRunM)）。
- 環境変数は現行の **publishable key** 命名を推奨（旧 `ANON_KEY` も後方互換で動作します）。

```bash
# .env.local
NEXT_PUBLIC_SUPABASE_URL=<your-project-url>
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<your-publishable-key>
```

```bash
npm install @supabase/supabase-js @supabase/ssr
```

### 2.2 鉄則：Cookieは `getAll` / `setAll` だけを使う

公式ドキュメントの最重要警告を、そのまま受け取ってください。

> **`getAll` と `setAll` だけを使うこと。`get` / `set` / `remove` は絶対に使わないこと。**

`get` / `set` / `remove` は**非推奨**であり、正しく実装するのが難しく、エッジケースに対応できません。これを破ると「ランダムなログアウト」「セッションの早期切断」「状態の不整合」という、再現しにくく厄介なバグを生みます（[creating-a-client](https://supabase.com/docs/guides/auth/server-side/creating-a-client)）。

### 2.3 ブラウザ用クライアント

```ts
// 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()` する形が正解です。

```ts
// 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がトークンを更新（書き込み）できない以上、**ミドルウェアがリクエストごとにトークンをリフレッシュする**役割を負います。これを省くと、アクセストークンが切れた瞬間に静かにログアウトします。

```ts
// 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;
}
```

```ts
// 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サーバーへの問い合わせになります。

```ts
// 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)](https://supabase.com/docs/guides/auth/server-side/nextjs) / [Creating a client](https://supabase.com/docs/guides/auth/server-side/creating-a-client) / [getClaims](https://supabase.com/docs/reference/javascript/auth-getclaims) / [JWT Signing Keys](https://supabase.com/docs/guides/auth/signing-keys)

---

## 3. Row Level Security（RLS）— DBに認可を寄せる

PostgRESTでテーブルをクライアントに公開する以上、**認可はDBの行レベルで強制する**のが大原則です。公式の言葉どおり「RLSは、クライアントからDBを直接クエリしても安全にするための仕組み」です。

> RLSの「なぜ」と、オフライン同時編集という極限ケースでの設計判断は、別記事「[クライアントを信じない設計：オフライン同時編集アプリで整合性と認可をPostgreSQLに寄せる](/blog/untrusted-client-postgres-rls-offline-first)」で実プロダクトを題材に深掘りしています。本節は**公式準拠のリファレンス**として、正しい書き方と性能を体系化します。

### 3.1 有効化とポリシーの形

```sql
-- まずテーブルでRLSを有効化（これを忘れると全公開）
alter table public.todos enable row level security;
```

ポリシーの基本形は次のとおりです。

```sql
create policy "<ポリシー名>"
on <テーブル>
for <select | insert | update | delete | all>
to <ロール>                  -- 例: authenticated, anon
using (<可視性・対象の条件>)   -- SELECT/UPDATE/DELETE：既存行のフィルタ
with check (<書き込み値の検証>); -- INSERT/UPDATE：新しい行の値の検証
```

操作ごとの実例（公式の現行スタイルに忠実）：

```sql
-- 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 ポリシー**で：

```sql
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` で判定 | — |

```sql
-- ①インデックス
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` を固定**してください。これを怠るとスキーマ汚染による権限昇格の入口になります。

```sql
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するより速く、宣言的です。

```sql
-- 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()` ヘルパー越しに権限を判定します。

```sql
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](https://supabase.com/docs/guides/database/postgres/row-level-security) / [Database Functions](https://supabase.com/docs/guides/database/functions) / [RBAC（Custom Claims）](https://supabase.com/docs/guides/database/postgres/custom-claims-and-role-based-access-control-rbac)

---

## 4. Realtime — Broadcast・Presence・Postgres Changes

Realtimeには3つの機能があります（[realtime](https://supabase.com/docs/guides/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 の基本（クライアント）

```ts
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変更をスケーラブルに配信する現行の正攻法です。トリガーでブロードキャストし、クライアントは**プライベートチャネル**で受けます。

```sql
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();
```

```ts
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`）を見て評価されます。

```sql
-- 例：そのルームのメンバーだけ受信を許可
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（オンライン状態）

```ts
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](https://supabase.com/docs/guides/realtime) / [Broadcast](https://supabase.com/docs/guides/realtime/broadcast) / [Subscribing to Database Changes](https://supabase.com/docs/guides/realtime/subscribing-to-database-changes) / [Realtime Authorization](https://supabase.com/docs/guides/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 などへ移植しやすい**のが利点です。

```ts
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ヘッダを読み回す必要はありません。

```ts
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と運用

```bash
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](https://supabase.com/docs/guides/functions/background-tasks)）。
- **リージョン指定呼び出し**：`supabase.functions.invoke('fn', { region: FunctionRegion.UsEast1 })`。DBに近いリージョンで実行してレイテンシを削る（[regional-invocation](https://supabase.com/docs/guides/functions/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](https://supabase.com/docs/guides/functions) / [Quickstart](https://supabase.com/docs/guides/functions/quickstart) / [Auth in Functions](https://supabase.com/docs/guides/functions/auth) / [Limits](https://supabase.com/docs/guides/functions/limits)

---

## 6. Storage — アップロードと署名URL、そしてRLS

```ts
// アップロード（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始まり）に分解できるのが定石です。

```sql
-- 各ユーザーは「自分の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](https://supabase.com/docs/guides/storage) / [Storage Access Control](https://supabase.com/docs/guides/storage/security/access-control)

---

## 7. AI / Vector（pgvector）— 既存DBがそのままベクトルDBになる

別途ベクトル専用DBを建てなくても、Postgresの **pgvector** 拡張でセマンティック検索が組めます。

```sql
-- 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`）：

```sql
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;
$$;
```

```ts
const { data } = await supabase.rpc("match_documents", {
  query_embedding: embedding, // クエリ文を埋め込んだベクトル
  match_threshold: 0.78,
  match_count: 10,
});
```

**インデックスは HNSW が現行の第一推奨**です（「性能とデータ変化への頑健性からHNSWを推奨」）。IVFFlatはデータ分布が変わると再構築が必要になるためです。

```sql
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](https://supabase.com/docs/guides/ai/automatic-embeddings)）。

> 一次情報: [AI & Vectors](https://supabase.com/docs/guides/ai) / [Vector Columns](https://supabase.com/docs/guides/ai/vector-columns) / [Semantic Search](https://supabase.com/docs/guides/ai/semantic-search) / [Vector Indexes](https://supabase.com/docs/guides/ai/vector-indexes)

---

## 8. 開発・運用の足回り（マイグレーション／ブランチ／Cron／Queues）

### 8.1 宣言的スキーマとマイグレーション

スキーマの**理想形**を `supabase/schemas/*.sql` に書き、差分からマイグレーションを生成する「宣言的スキーマ」と、従来のマイグレーションを併用できます。

```bash
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（カスタム）という構成ですが、各上限値や従量単価、レプリカ/ブランチの提供条件は改定されます。**金額は必ず[公式の料金ページ](https://supabase.com/pricing)で最新を確認**してください（本稿では確定値の断定を避けます）。

> 一次情報: [Declarative Schemas](https://supabase.com/docs/guides/local-development/declarative-database-schemas) / [Migrations](https://supabase.com/docs/guides/deployment/database-migrations) / [Branching](https://supabase.com/docs/guides/deployment/branching) / [Read Replicas](https://supabase.com/docs/guides/platform/read-replicas) / [Cron](https://supabase.com/docs/guides/cron) / [Queues](https://supabase.com/docs/guides/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時点の[公式ドキュメント](https://supabase.com/docs)に基づきます。Supabaseは更新が速いため、実装前に各節の一次情報リンクで最新を確認することを強くおすすめします。
