Claude Code × Supabase フルスタック開発完全ガイド【2026年最新】|MCP連携・RLS自動生成・Realtime・Edge Functions・pgvectorまで

Claude Code × Supabase フルスタック開発完全ガイド【2026年最新】|MCP連携・RLS自動生成・Realtime・Edge Functions・pgvectorまで AI開発

Supabase は Postgres をコアに Auth / Realtime / Storage / Edge Functions / pgvector を 1 つのプロジェクトに束ねた、2026 年でもっとも勢いのある BaaS です。一方 Claude Code は、SQL を書かせると RLS を壊す、マイグレーションを書かせると CI で落ちる、Edge Functions を書かせると Deno import が通らない——個々の技術を個別に知らないと途端にハマります。

この記事は Claude Code で Supabase プロジェクトを「安全に・速く・間違いなく」構築するための完全ガイドです。公式 Supabase MCP サーバーの OAuth 接続新 API キー(sb_publishable / sb_secret)対応getClaims() ベースの Next.js App Router 認証Claude Code に RLS ポリシーを書かせても漏れない 4 段レビューRealtime / Storage / Edge Functions / pgvector の実践パターンまで、2026 年 4 月時点で実戦投入できる内容だけを詰め込みました。

スポンサーリンク

なぜ Claude Code × Supabase なのか

個人開発から MVP、受託、スタートアップまで、同じスタックで動くフルスタック環境を求める開発者が増えています。Claude Code と Supabase の組み合わせが刺さる理由は大きく 3 つです。

観点 なぜ強いか
公式 MCP サーバーの成熟 2026 年時点で OAuth 認証・読み取り専用モード・プロジェクト固定スコープを備え、Claude Code から execute_sql / apply_migration / deploy_edge_function / generate_typescript_types まで直接呼べます
スキーマ駆動でコンテキストが揃う テーブル定義から TypeScript 型・ORM・API・UI を連鎖生成できる。Claude Code は generate_typescript_types の結果を前提にコードを書くので、型の嘘がそのまま事故になる Firebase 系より安全です
Postgres の厚みがそのまま使える Row Level Security・拡張(pgvector、pg_cron、pgmq、pg_graphql)・トリガ・関数が LLM の「これで良いですか?」の検証対象になり、Claude Code の推測が DB 側の仕組みで裏を取れます
結論: Claude Code の強みである「CLAUDE.md によるルール駆動」と、Supabase の「Postgres でなんでもできる」が噛み合うと、初期構築の所要時間が体感で 1/3〜1/5 になります。ただし RLS と鍵管理だけは油断すると即データ漏えいなので、後述の 4 段レビューは必ず仕込んでください。

プロジェクト初期化と前提ツール

Claude Code と Supabase CLI、Docker Desktop(もしくは Docker Engine)を揃えておきます。2026 年 4 月時点では Supabase CLI v2 系と Node.js 22 LTS / Bun 1.2 系を想定します。

必要なツールとバージョン確認
claude --version          # Claude Code 2.x 系
supabase --version        # Supabase CLI v2 系
node -v                   # v22 LTS 推奨(Bun の場合は bun -v)
docker info               # supabase start が内部で使う
deno --version            # Edge Functions ローカル実行に必要
プロジェクト作成(Next.js 15 + Supabase のフル構成)
# Next.js アプリ雛形
npx create-next-app@latest my-app --ts --app --tailwind --eslint --src-dir --import-alias "@/*"
cd my-app

# Supabase クライアント
npm install @supabase/supabase-js @supabase/ssr

# Supabase CLI で backend を初期化
supabase init               # supabase/ ディレクトリ生成
supabase start              # ローカル Supabase(Postgres / Studio / Auth / Realtime)起動
supabase link --project-ref <YOUR_PROJECT_REF>   # 本番プロジェクトに紐づけ
新 API キー体系(2025-11 以降の新規プロジェクトで既定): 従来の anon / service_role は段階的に sb_publishable_... / sb_secret_... に置き換わります。publishable キーはクライアントに露出してよい鍵、secret キーはサーバーのみで使う鍵です。Claude Code に書かせるコードでは「publishable はブラウザ / Edge Functions の署名検証側、secret は Edge Functions 内で Deno.env.get() から取る」を原則にしてください。
.env.local(Next.js 側)
NEXT_PUBLIC_SUPABASE_URL=https://xxxxxxxx.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_xxxxxxxxxxxxxxxx
# secret キーはサーバーのみ。絶対に NEXT_PUBLIC_ を付けない
SUPABASE_SECRET_KEY=sb_secret_xxxxxxxxxxxxxxxx

Supabase MCP サーバーを Claude Code に接続する

Supabase は公式のリモート MCP サーバー(https://mcp.supabase.com/mcp)を提供しています。以前は個人アクセストークン(PAT)が必須でしたが、2026 年は OAuth フローでブラウザ認証するのが標準です。Claude Code のユーザースコープに登録すれば、どのプロジェクトからでも同じ MCP が呼べます。

Supabase MCP をユーザースコープに追加(OAuth)
# 読み取り専用 + プロジェクト固定が安全デフォルト
claude mcp add --scope user supabase \
  --transport http \
  "https://mcp.supabase.com/mcp?project_ref=<PROJECT_REF>&read_only=true"

# 初回 /mcp コマンド実行時にブラウザが開き Supabase にログインする
# 完了後に /mcp の出力で supabase が connected と表示されれば OK
起動時のセキュリティ 3 原則:
read_only=true を必ず付ける(Claude に DDL/DML を直接実行させない)
project_ref でスコープを 1 プロジェクトに固定する(他プロジェクトを誤って触らない)
③ 本番(prod)プロジェクトには接続しない。開発ブランチか dev プロジェクトに限定する

Supabase MCP で使える主なツール

ツール 用途 危険度
list_tables スキーマ探索
execute_sql SELECT で状態確認(read_only=true で SELECT のみ)
apply_migration マイグレーションファイル適用 高(prod 厳禁)
deploy_edge_function Deno 製 Edge Function のデプロイ
generate_typescript_types スキーマから Database 型生成
get_logs / get_advisors ログ・セキュリティ/パフォ助言の取得
search_docs Supabase 公式ドキュメント検索
使い分け指針: 日常の探索と型生成は MCP、マイグレーション本体の作成と適用はローカル CLI(supabase migration new + supabase db push)に寄せます。MCP はあくまでも「読ませる・問い合わせる・型を渡す」用途に限定すると事故が劇的に減ります。

Supabase 向け CLAUDE.md テンプレート

CLAUDE.md はプロジェクトのルート CLAUDE.md とホームの ~/.claude/CLAUDE.md の 2 層を使い分けます。下記は Next.js App Router + Supabase 構成のプロジェクトルート向けの最小構成です。

CLAUDE.md(プロジェクトルート)
# Supabase プロジェクト運用ルール

## スタック
- Next.js 15 App Router + React 19
- Supabase(Auth / Postgres / Realtime / Storage / Edge Functions / pgvector)
- クライアント: @supabase/ssr v0.x(createBrowserClient / createServerClient を使い分け)

## API キー方針
- 公開鍵は必ず sb_publishable_ 系(旧 anon は新規追加禁止)
- サーバー鍵は sb_secret_ 系。NEXT_PUBLIC_ プリフィックス禁止
- Edge Functions 内では Deno.env.get("SUPABASE_SECRET_KEY") のみ使用

## 認証
- Server Components / Server Actions では必ず createServerClient + supabase.auth.getClaims() を使う
- サーバー側で getSession() は使わない(Cookie を検証せず改ざんを見抜けない)
- getUser() は API 往復するため安全だが遅い。基本は getClaims() に寄せる
- middleware.ts でリクエストごとに getClaims() を呼びトークンを更新する

## DB とマイグレーション
- SQL は supabase/migrations/*.sql に配置(直接 DB を触らない)
- 命名規則: YYYYMMDDHHMMSS_short_description.sql
- すべてのテーブルに RLS を有効化。ポリシーなしの ENABLE は禁止
- 例外: public.app_config のような完全公開テーブルのみコメントで明記

## RLS ルール
- auth.uid() は必ず呼び側でキャッシュする: (select auth.uid())
- using 句と with check 句を両方書く(INSERT/UPDATE/DELETE のすべてで)
- ポリシー追加時は必ず pg_policies のスナップショットを確認する

## Edge Functions
- 1 関数 1 責務。import は jsr: or npm: 指定を使う
- --no-verify-jwt は明示的に理由をコメント
- CORS は共通 util(supabase/functions/_shared/cors.ts)を使う

## Claude Code 実行ルール
- supabase db push を実行する前に git diff supabase/migrations を提示する
- Supabase MCP の execute_sql は SELECT 以外は実行しない
- Edge Functions のデプロイは必ず supabase functions deploy --use-api でローカル検証 → デプロイ
なぜプロジェクト単位に書くか: Supabase は 1 つの誤ったポリシーで全ユーザーの行が見える状態を作れてしまいます。CLAUDE.md に RLS 規約を書いておくと、Claude Code が CREATE POLICY を書くたびにこのチェックリストを適用してくれるため、手動レビューの負担が激減します。

スキーマ管理とマイグレーション

Supabase は Postgres そのものなので、マイグレーションファイルが正義です。Claude Code には「supabase migration new でファイルを生成 → 編集 → supabase db push で適用」というワークフローをテンプレ化させます。

マイグレーション作成プロンプト例
タスク: 投稿(posts)と コメント(comments)のテーブルを追加する

要件:
- posts: id uuid PK, user_id uuid FK auth.users, title text, body text, created_at timestamptz default now()
- comments: id uuid PK, post_id uuid FK posts on delete cascade, user_id uuid FK auth.users, body text, created_at timestamptz
- posts / comments とも RLS 有効化
- RLS ポリシー:
  * SELECT: 公開(誰でも読める)
  * INSERT: auth.uid() が user_id と一致する場合のみ
  * UPDATE/DELETE: 作成者本人のみ
- supabase migration new posts_comments_init で新規ファイルを作り、上記を SQL として書き込む
- ポリシーの auth.uid() は必ず (select auth.uid()) 形式でキャッシュする
- 完了後に SELECT で pg_policies を表示して確認
supabase/migrations/20260421120000_posts_comments_init.sql
-- posts / comments のスキーマ定義
create table public.posts (
  id         uuid primary key default gen_random_uuid(),
  user_id    uuid not null references auth.users(id) on delete cascade,
  title      text not null,
  body       text not null default '',
  created_at timestamptz not null default now()
);

create table public.comments (
  id         uuid primary key default gen_random_uuid(),
  post_id    uuid not null references public.posts(id) on delete cascade,
  user_id    uuid not null references auth.users(id) on delete cascade,
  body       text not null,
  created_at timestamptz not null default now()
);

-- RLS
alter table public.posts    enable row level security;
alter table public.comments enable row level security;

-- SELECT(誰でも読める想定)
create policy "posts_select_all"
  on public.posts for select
  using (true);

create policy "comments_select_all"
  on public.comments for select
  using (true);

-- INSERT(自分の行のみ)
create policy "posts_insert_self"
  on public.posts for insert
  with check ((select auth.uid()) = user_id);

create policy "comments_insert_self"
  on public.comments for insert
  with check ((select auth.uid()) = user_id);

-- UPDATE/DELETE(所有者のみ)
create policy "posts_modify_owner"
  on public.posts for update using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);

create policy "posts_delete_owner"
  on public.posts for delete using ((select auth.uid()) = user_id);

create policy "comments_modify_owner"
  on public.comments for update using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);

create policy "comments_delete_owner"
  on public.comments for delete using ((select auth.uid()) = user_id);
適用と型生成
# ローカル DB に適用(supabase start で起動済み前提)
supabase db push

# 本番に適用するなら必ずプルリク経由。ローカルで十分確認してから
supabase db push --linked

# DB スキーマから型を自動生成(TS 定義として src/types/database.ts に保存)
supabase gen types typescript --local > src/types/database.ts
絶対にやってはいけない: MCP の apply_migration で本番プロジェクトに直接スキーマを当てること。必ずローカル → ブランチ(Supabase Preview Branches)→ 本番 の順序で反映してください。Supabase は GitHub と連動した自動 Preview Branch を備えているので、PR ごとに使い捨ての DB を用意できます。

Claude Code に RLS を書かせても漏れない 4 段レビュー

RLS は Supabase の心臓部です。1 行の書き忘れで全ユーザーの行が見える事故が起きる一方、厳しすぎると自分の行すら取れなくなります。Claude Code に RLS を書かせる場合は、以下の 4 段階で検証するワークフローを CLAUDE.md に固定化してください。

段階 やること ツール
① 静的レビュー ポリシーの using / with check の組み合わせを diff でチェック Claude Code の Edit + Git diff
② 自動アドバイザ get_advisors で SECURITY / PERFORMANCE の警告を抽出 Supabase MCP
③ 実データテスト 匿名 JWT・他ユーザー JWT での SELECT/INSERT/UPDATE/DELETE を試す supabase-js + テストスクリプト
④ 回帰スナップショット pg_policies のスナップショットを取り、PR 差分としてレビュー対象に Claude Code Hooks(PreToolUse)
rls.test.ts(②③の自動化例 / Vitest)
import { describe, it, expect, beforeAll } from "vitest";
import { createClient } from "@supabase/supabase-js";

// 匿名クライアント(未ログイン)と 2 人のユーザークライアントを用意
const anon   = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_PUBLISHABLE_KEY!);
const alice  = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_PUBLISHABLE_KEY!);
const bob    = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_PUBLISHABLE_KEY!);

beforeAll(async () => {
  await alice.auth.signInWithPassword({ email: "alice@test.dev",  password: "pw" });
  await bob.auth.signInWithPassword({   email: "bob@test.dev",    password: "pw" });
});

describe("RLS: posts", () => {
  it("匿名ユーザーは SELECT できるが INSERT はできない", async () => {
    const read = await anon.from("posts").select("id").limit(1);
    expect(read.error).toBeNull();
    const write = await anon.from("posts").insert({ title: "x", body: "x" });
    expect(write.error).not.toBeNull();
  });

  it("Alice は自分の投稿は更新できるが Bob の投稿は更新できない", async () => {
    const { data: aPost } = await alice.from("posts").insert({ title: "A", body: "a" }).select().single();
    const own = await alice.from("posts").update({ title: "A2" }).eq("id", aPost!.id);
    expect(own.error).toBeNull();
    const foreign = await bob.from("posts").update({ title: "A-hacked" }).eq("id", aPost!.id);
    expect(foreign.error).not.toBeNull();   // 更新失敗 or 行数 0 が正
  });
});
Hooks で④を強制する: .claude/hooks/pre_tool_use.sh から pg_dump --schema-only --table=pg_catalog.pg_policies を流して差分が出たら警告、のような仕掛けにしておくと、Claude Code が RLS を書き換えたのに気づかないままマージしてしまう事故を防げます。Claude Code の Hooks については Claude Code Hooks 完全ガイド で詳しく解説しています。

Next.js App Router と @supabase/ssr で認証を実装する

App Router 環境では @supabase/ssr を介して「ブラウザ用」「サーバー用」「ミドルウェア用」の 3 種類のクライアントを使い分けます。2026 年は supabase.auth.getClaims() が推奨 API で、JWT 署名を公開鍵で検証してから主張(claims)を取り出すため、サーバーサイドでも安全に認証判定ができます。

src/lib/supabase/server.ts(Server Components / Actions 用)
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function getSupabaseServer() {
  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 Components から呼ばれた時は set 不可(無視して OK)
          }
        },
      },
    }
  );
}
src/lib/supabase/client.ts(ブラウザ用)
import { createBrowserClient } from "@supabase/ssr";

export const getSupabaseBrowser = () =>
  createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
  );
src/middleware.ts(セッション更新と保護)
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

export async function middleware(request: NextRequest) {
  let response = 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));
          response = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options)
          );
        },
      },
    }
  );

  // JWT 署名検証つきで claims を取る(推奨)
  const { data: { claims } } = await supabase.auth.getClaims();

  // /app 以下はログイン必須
  if (!claims && request.nextUrl.pathname.startsWith("/app")) {
    const url = request.nextUrl.clone();
    url.pathname = "/login";
    return NextResponse.redirect(url);
  }

  return response;
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
サーバー側で getSession() を信頼してはいけません。 これは Cookie からセッション情報を読むだけで JWT 署名検証をしないため、Cookie を偽装されると誤ってログイン済みと判定する余地があります。getUser() は Supabase API への往復で検証するため安全ですが、リクエストごとにレイテンシが追加されます。getClaims() は Supabase の公開鍵で JWT 署名をローカル検証するため、安全性とパフォーマンスを両立できます。2026 年はサーバー側の認可判定をすべて getClaims() に寄せるのが鉄則です。

Realtime ── Postgres Changes / Presence / Broadcast を使い分ける

Supabase Realtime には 3 種類のメッセージングモードがあります。Claude Code に「とりあえずリアルタイム」と依頼すると Postgres Changes を選びがちですが、購読者が増えるとスケールしないため、用途ごとに正しく使い分けてもらう必要があります。

モード 向いている用途 注意点
Postgres Changes DB 行の変更を直接配信(コメント追加の反映など) 1 件の INSERT に対し購読者数だけ読み込みが発生。DELETE はフィルタ不可
Broadcast タイピング中通知、カーソル位置、ゲームイベント DB を経由しないのでスケール最強。永続化なし
Presence 同時接続ユーザーの可視化(オンライン状態) 同期コストがあるため 1 チャンネルあたりの人数を抑える
Postgres Changes を有効化する
-- 対象テーブルを supabase_realtime パブリケーションに追加
alter publication supabase_realtime add table public.comments;

-- DELETE で削除行の中身が必要ならレプリカアイデンティティを full に
alter table public.comments replica identity full;
React 側(Postgres Changes 購読)
"use client";
import { useEffect, useState } from "react";
import { getSupabaseBrowser } from "@/lib/supabase/client";

export function CommentStream({ postId }: { postId: string }) {
  const [comments, setComments] = useState<{ id: string; body: string }[]>([]);

  useEffect(() => {
    const supabase = getSupabaseBrowser();
    const channel = supabase
      .channel(`comments:${postId}`)
      .on(
        "postgres_changes",
        {
          event: "INSERT",
          schema: "public",
          table: "comments",
          filter: `post_id=eq.${postId}`,
        },
        (payload) => {
          setComments((prev) => [...prev, payload.new as any]);
        }
      )
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, [postId]);

  return <ul>{comments.map((c) => <li key={c.id}>{c.body}</li>)}</ul>;
}
スケール判断: 1 つの INSERT を 100 人が見るなら DB を 100 回読みにいきます。ユーザー数が多いチャットやダッシュボードは、Edge Function 側で DB に書いてから Broadcast で一斉配信するパターンに切り替えるとコスト激減です。WebSocket / SSE を含む設計指針は Claude Code × リアルタイムアプリ開発ガイド もあわせてどうぞ。

Storage ── RLS 付きオブジェクトストレージ

Storage はバケット単位でアクセス制御を行います。storage.objects テーブルにも RLS が効くため、通常の RLS と同じ流儀でポリシーを書けます。画像の公開バケットと、ユーザー専用プライベートバケットは必ず分離するのが鉄則です。

public / private バケットと RLS ポリシー
-- バケット作成(Studio UI でも可)
insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', true),
       ('user-files', 'user-files', false);

-- 公開バケット: 誰でも読める / 認証ユーザーのみアップロード可
create policy "avatars_public_read"
  on storage.objects for select
  using (bucket_id = 'avatars');

create policy "avatars_auth_insert"
  on storage.objects for insert
  with check (
    bucket_id = 'avatars'
    and (select auth.uid()) is not null
  );

-- プライベートバケット: 自分のフォルダのみアクセス可
create policy "user_files_own_read"
  on storage.objects for select
  using (
    bucket_id = 'user-files'
    and (storage.foldername(name))[1] = (select auth.uid())::text
  );

create policy "user_files_own_write"
  on storage.objects for insert
  with check (
    bucket_id = 'user-files'
    and (storage.foldername(name))[1] = (select auth.uid())::text
  );
クライアントからアップロード
const supabase = getSupabaseBrowser();
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error("未ログイン");

const file = e.target.files![0];
const path = `${user.id}/${crypto.randomUUID()}-${file.name}`;

const { error } = await supabase.storage
  .from("user-files")
  .upload(path, file, {
    cacheControl: "3600",
    upsert: false,
    contentType: file.type,
  });

if (error) console.error(error);
プライベートファイルの配信: 直接の URL 公開は避け、createSignedUrl(path, expiresInSec) で期限付き URL を発行してください。画像最適化が必要な場合は Supabase Image Transformations(transform オプション)を使うと CDN 経由でリサイズできます。

Edge Functions ── Deno でサーバーレス処理を書く

Edge Functions は Deno ランタイムで動くサーバーレス関数です。Claude Code に書かせる場合は、import は必ず jsr: または npm: を使うCORS は共通ユーティリティに切り出すsecret キーは Deno.env.get() から取るの 3 原則を CLAUDE.md に明記しておきます。

関数の作成とローカル起動
supabase functions new summarize
# supabase/functions/summarize/index.ts が生成される

# ローカル実行(別ターミナルで supabase start 済み前提)
supabase functions serve summarize --env-file supabase/functions/.env.local

# 呼び出し確認
curl -i --location --request POST http://127.0.0.1:54321/functions/v1/summarize \
  --header "Authorization: Bearer <ANON_OR_PUBLISHABLE>" \
  --header "Content-Type: application/json" \
  --data '{"text":"長文..."}' 
supabase/functions/_shared/cors.ts
export const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers":
    "authorization, x-client-info, apikey, content-type",
  "Access-Control-Allow-Methods": "POST, OPTIONS",
};
supabase/functions/summarize/index.ts(OpenAI で要約する例)
import { corsHeaders } from "../_shared/cors.ts";

Deno.serve(async (req) => {
  if (req.method === "OPTIONS") {
    return new Response("ok", { headers: corsHeaders });
  }

  const { text } = await req.json();
  if (!text) {
    return new Response(JSON.stringify({ error: "text required" }), {
      status: 400,
      headers: { ...corsHeaders, "Content-Type": "application/json" },
    });
  }

  const openaiKey = Deno.env.get("OPENAI_API_KEY");
  if (!openaiKey) {
    return new Response(JSON.stringify({ error: "server misconfigured" }), {
      status: 500,
      headers: { ...corsHeaders, "Content-Type": "application/json" },
    });
  }

  const res = await fetch("https://api.openai.com/v1/chat/completions", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${openaiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model: "gpt-4o-mini",
      messages: [
        { role: "system", content: "入力を 3 行で日本語要約せよ。" },
        { role: "user", content: text },
      ],
    }),
  });

  const data = await res.json();
  const summary = data.choices?.[0]?.message?.content ?? "";

  return new Response(JSON.stringify({ summary }), {
    headers: { ...corsHeaders, "Content-Type": "application/json" },
  });
});
secret の登録とデプロイ
# 本番用 secret を登録
supabase secrets set OPENAI_API_KEY=sk-xxxxxxxxxxxx

# デプロイ(JWT 検証あり = 認証必須)
supabase functions deploy summarize

# 認証不要(Webhook 用途など)の場合のみ明示的に
supabase functions deploy public-webhook --no-verify-jwt
--no-verify-jwt は「誰でも叩ける」状態になります。Webhook のように外部から匿名で呼ばれる関数だけに限定し、関数内部で署名検証(Stripe なら Stripe-Signature)を必ず実装してください。Claude Code に任せきりだと「便利だから」とこのフラグを付けがちなので、CLAUDE.md の Edge Functions セクションに明確な禁止理由を書いておきます。

pgvector で RAG・セマンティック検索を構築する

Supabase は pgvector 拡張を有効化するだけでベクトル検索基盤になります。Edge Function で embedding を生成し、Postgres 関数(RPC)で類似検索を行うのが定番構成です。2026 年時点では text-embedding-3-small(1536 次元)か、ローカル実行なら nomic-embed-text-v2(768 次元)が候補です。

スキーマと RPC 関数
-- 拡張を有効化
create extension if not exists vector with schema extensions;

-- ドキュメントテーブル(1536 次元を想定)
create table public.documents (
  id         bigint generated always as identity primary key,
  content    text not null,
  embedding  extensions.vector(1536),
  metadata   jsonb not null default '{}'::jsonb
);

-- HNSW インデックス(INSERT と検索のバランスが良い)
create index documents_embedding_hnsw
  on public.documents using hnsw (embedding vector_cosine_ops);

-- RPC: 類似ドキュメント検索
create or replace function match_documents(
  query_embedding vector(1536),
  match_threshold float,
  match_count int
)
returns table (
  id bigint,
  content text,
  similarity float
)
language sql stable as $$
  select
    d.id,
    d.content,
    1 - (d.embedding <=> query_embedding) as similarity
  from public.documents d
  where 1 - (d.embedding <=> query_embedding) > match_threshold
  order by d.embedding <=> query_embedding
  limit match_count;
$$;
Edge Function で embedding → 検索
// supabase/functions/ask/index.ts
import { corsHeaders } from "../_shared/cors.ts";
import { createClient } from "jsr:@supabase/supabase-js@2";

Deno.serve(async (req) => {
  if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });

  const { query } = await req.json();

  // 1) OpenAI で embedding を作る
  const embRes = await fetch("https://api.openai.com/v1/embeddings", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${Deno.env.get("OPENAI_API_KEY")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ model: "text-embedding-3-small", input: query }),
  });
  const { data } = await embRes.json();
  const embedding = data[0].embedding;

  // 2) Supabase の RPC で類似検索(SERVICE キーで管理者権限)
  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!    // 旧名。新 API では sb_secret_
  );
  const { data: hits, error } = await supabase.rpc("match_documents", {
    query_embedding: embedding,
    match_threshold: 0.75,
    match_count: 5,
  });

  if (error) {
    return new Response(JSON.stringify({ error: error.message }), {
      status: 500,
      headers: { ...corsHeaders, "Content-Type": "application/json" },
    });
  }

  return new Response(JSON.stringify({ hits }), {
    headers: { ...corsHeaders, "Content-Type": "application/json" },
  });
});
HNSW か IVFFlat か: HNSW は検索速度と精度が高く、INSERT 時のインデックス更新コストもそこそこ。数百万行までの実務ユースケースでは HNSW が既定で良いです。億単位のデータや、まとめて再ビルドできる用途では IVFFlat が選ばれます。Claude Code に選ばせるなら CLAUDE.md に「デフォルト HNSW、行数見込みが 1000 万超で IVFFlat 検討」のようにルール化しておきます。

落とし穴とセキュリティ

RLS を有効化し忘れる

もっとも多い事故です。create table だけ書いて RLS を忘れると、publishable キー 1 つで世界中から全行が取り放題になります。Supabase ダッシュボードの Advisors に警告が出ますが、Claude Code に書かせたコードではレビューでしか気づけません。

RLS の一括確認クエリ(定期実行推奨)
select
  schemaname, tablename, rowsecurity
from pg_tables
where schemaname = 'public'
  and rowsecurity = false;
-- 結果が 0 行であることが正常

service_role(または sb_secret_)キーをフロントに露出させる

secret キーは RLS を完全にバイパスします。NEXT_PUBLIC_ プリフィックスを付けてビルドしたら、バンドル内に平文で含まれてしまい実質公開状態です。secret キーは Edge Functions か Node.js サーバーの process.env / Deno.env からしか読まない——この一点だけは Claude Code にも絶対にお願いしないでください。

auth.uid() を毎行で再評価してしまう

RLS ポリシー内で auth.uid() = user_id と書くと、行ごとに関数が呼ばれてパフォーマンスが劣化します。(select auth.uid()) = user_id と書き換えるだけで実行計画上 1 度だけ評価されるようになります。大量行のテーブルでは 10 倍以上の差が出ることもあります。

Realtime のスケールを読み違える

Postgres Changes は便利ですが、購読者が増えると DB の読み込みが線形に増えます。オンラインゲームや大規模チャットでは Broadcast に切り替えるか、Edge Function を経由して配信量を絞る設計が必要です。

–no-verify-jwt の乱用

Edge Function の認証を外すフラグは「Webhook 用」以外では原則禁止にしましょう。外部 Webhook の場合も、関数内で署名ヘッダ(Stripe: stripe-signature、GitHub: x-hub-signature-256)を必ず検証します。

MCP で本番プロジェクトに接続する

Claude Code の誤解釈で prod に DDL が流れる事故を避けるため、MCP は必ず dev プロジェクトか Preview Branch のみに接続してください。また read_only=true を外す場合は、ターミナル 1 回ごとに手動で外す運用にすると、常時フルアクセスの危険が減ります。

よくある質問

QSupabase CLI のマイグレーションと Prisma / Drizzle は共存できますか?
A可能ですが主従を決めてください。推奨は「DDL は supabase/migrations、アプリ側は Drizzle(または Prisma)で型だけ使う」です。Drizzle は drizzle-kit introspect:pg で既存スキーマから型を生成できるので、Supabase 側の DDL を正とする運用と相性が良いです。ORM 側でマイグレーションを走らせると Supabase 管理画面からの Advisors が効きにくくなるため、DDL は片方に寄せるのが鉄則です。詳しくは Claude Code × データベース開発完全ガイド を参照してください。
QSupabase Auth と Clerk / Better Auth はどちらを使うべきですか?
ASupabase プロジェクトで DB を Supabase に寄せているなら Supabase Auth が圧倒的に楽です。RLS と auth.uid() が直接連動するため、Auth プロバイダを別サービスにすると JWT のマッピングで手間が増えます。一方、マルチテナント SSO や複雑な組織管理が必要なら Clerk、TypeScript ネイティブで ORM と密結合したいなら Better Auth が候補に入ります。認証ライブラリ横断の設計比較は Claude Codeで認証/認可を実装する実践ガイド にまとめてあります。
QEdge Functions と Next.js の Route Handler はどう使い分けますか?
A「Next.js アプリの内部 API」は Route Handler、「外部から Webhook で叩かれる」「重い処理を独立デプロイしたい」「複数フロントから共通利用したい」は Edge Functions が向いています。Next.js を Vercel に載せるなら Route Handler も Edge 実行できますが、Supabase プロジェクトと同じリージョンに Edge Function を置けば DB までのレイテンシが最小化できます。
QSupabase MCP だけでマイグレーションまで任せてもいいですか?
Aおすすめしません。MCP 経由の apply_migration はファイル管理を CLI と別系統にするため、PR レビューで差分が追えなくなります。マイグレーション本体は常に supabase/migrations/ 配下にファイルとして置き、Claude Code には「そのファイルを読んで SQL を書き足す」タスクに限定させるのが安全です。
QRLS をオフにして service_role で全部やればシンプルになりますか?
A短期的には楽ですが、長期的には事故が増えます。ユーザーからの書き込みを受ける API がすべて service_role を握る設計になると、1 つのエンドポイントのバグで全行が書き換えられるリスクを抱えます。RLS は「DB 側で最後の砦を作る」仕組みなので、アプリのバグをすり抜けても DB が防いでくれる点に価値があります。
Qpgvector で 1 億行を検索できますか?
A可能ですが設計次第です。HNSW は速い反面インデックスがメモリに乗りきらないと遅くなります。1000 万行を超えるなら、メタデータでパーティションを切る・tenant_id でフィルタを効かせる・IVFFlat を使い list 数をチューニングする、のいずれかを検討してください。また埋め込みモデルを 1536 次元 → 768 次元に下げるだけでもストレージとメモリが半減します。
Q本番直前のマイグレーションは Claude Code に任せて大丈夫ですか?
APreview Branch でドライランするなら OK です。Supabase は GitHub 連携で PR ごとに一時 DB を自動作成できるため、マイグレーションと Edge Functions の検証を本番と切り離して行えます。CLAUDE.md に「本番反映は必ず Preview Branch 経由」と書いておき、Claude Code に直接 supabase db push --linked を打たせないフックを仕込むと安心です。ハーネス設計の考え方は Claude Codeハーネスエンジニアリング実践 を参考にしてください。

まとめ

  • 新 API キー(sb_publishable / sb_secret)対応: publishable はクライアント、secret はサーバー専用。NEXT_PUBLIC_ プリフィックスを secret に付けないルールを CLAUDE.md で固定化する
  • Supabase MCP は read_only + project_ref 固定で接続: 本番プロジェクト直結は禁止。探索と型生成に限定し、マイグレーションはローカル CLI に寄せる
  • RLS は 4 段レビュー: 静的レビュー → get_advisors → 実データテスト → pg_policies スナップショット。1 段階でも欠けると事故の温床になる
  • 認証は getClaims() に統一: サーバー側で getSession() / getUser() を信頼しない。middleware で毎回トークン更新し JWT 署名検証を挟む
  • Realtime は用途で使い分け: DB 反映は Postgres Changes、同時接続状態は Presence、大量ブロードキャストは Broadcast。規模が読めたら Edge Function 経由で再配信にシフトする
  • Edge Functions は secret と CORS を共通化: _shared/cors.ts を作り、--no-verify-jwt は Webhook 限定のルールを明文化する
  • pgvector は HNSW がデフォ: 1000 万行超で IVFFlat 検討。Edge Function で embedding を作り、RPC match_documents で検索

Supabase は Postgres の厚みを Claude Code が活かしきれる珍しいスタックです。CLAUDE.md・MCP・Hooks・Skills を組み合わせて、速さと安全性を両立したフルスタック開発ラインを組み上げてください。関連する実践記事として Claude Code × Next.js フルスタック開発完全ガイドClaude Codeで認証/認可を実装する実践ガイドClaude Code × リアルタイムアプリ開発実践ガイドClaude Code × データベース開発完全ガイド もあわせてご覧ください。