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 なのか
- プロジェクト初期化と前提ツール
- Supabase MCP サーバーを Claude Code に接続する
- Supabase 向け CLAUDE.md テンプレート
- スキーマ管理とマイグレーション
- Claude Code に RLS を書かせても漏れない 4 段レビュー
- Next.js App Router と @supabase/ssr で認証を実装する
- Realtime ── Postgres Changes / Presence / Broadcast を使い分ける
- Storage ── RLS 付きオブジェクトストレージ
- Edge Functions ── Deno でサーバーレス処理を書く
- pgvector で RAG・セマンティック検索を構築する
- 落とし穴とセキュリティ
- よくある質問
- まとめ
なぜ 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 と 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 アプリ雛形 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> # 本番プロジェクトに紐づけ
anon / service_role は段階的に sb_publishable_... / sb_secret_... に置き換わります。publishable キーはクライアントに露出してよい鍵、secret キーはサーバーのみで使う鍵です。Claude Code に書かせるコードでは「publishable はブラウザ / Edge Functions の署名検証側、secret は Edge Functions 内で Deno.env.get() から取る」を原則にしてください。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 が呼べます。
# 読み取り専用 + プロジェクト固定が安全デフォルト 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
①
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 公式ドキュメント検索 | 低 |
supabase migration new + supabase db push)に寄せます。MCP はあくまでも「読ませる・問い合わせる・型を渡す」用途に限定すると事故が劇的に減ります。Supabase 向け CLAUDE.md テンプレート
CLAUDE.md はプロジェクトのルート CLAUDE.md とホームの ~/.claude/CLAUDE.md の 2 層を使い分けます。下記は Next.js App Router + Supabase 構成のプロジェクトルート向けの最小構成です。
# 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 は 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 を表示して確認
-- 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
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) |
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 が正
});
});
.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)を取り出すため、サーバーサイドでも安全に認証判定ができます。
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)
}
},
},
}
);
}
import { createBrowserClient } from "@supabase/ssr";
export const getSupabaseBrowser = () =>
createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
);
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 チャンネルあたりの人数を抑える |
-- 対象テーブルを supabase_realtime パブリケーションに追加 alter publication supabase_realtime add table public.comments; -- DELETE で削除行の中身が必要ならレプリカアイデンティティを full に alter table public.comments replica identity full;
"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>;
}
Storage ── RLS 付きオブジェクトストレージ
Storage はバケット単位でアクセス制御を行います。storage.objects テーブルにも RLS が効くため、通常の 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);
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":"長文..."}'
export const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
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 を登録 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 次元)が候補です。
-- 拡張を有効化
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;
$$;
// 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" },
});
});
落とし穴とセキュリティ
RLS を有効化し忘れる
もっとも多い事故です。create table だけ書いて RLS を忘れると、publishable キー 1 つで世界中から全行が取り放題になります。Supabase ダッシュボードの Advisors に警告が出ますが、Claude Code に書かせたコードではレビューでしか気づけません。
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 回ごとに手動で外す運用にすると、常時フルアクセスの危険が減ります。
よくある質問
drizzle-kit introspect:pg で既存スキーマから型を生成できるので、Supabase 側の DDL を正とする運用と相性が良いです。ORM 側でマイグレーションを走らせると Supabase 管理画面からの Advisors が効きにくくなるため、DDL は片方に寄せるのが鉄則です。詳しくは Claude Code × データベース開発完全ガイド を参照してください。auth.uid() が直接連動するため、Auth プロバイダを別サービスにすると JWT のマッピングで手間が増えます。一方、マルチテナント SSO や複雑な組織管理が必要なら Clerk、TypeScript ネイティブで ORM と密結合したいなら Better Auth が候補に入ります。認証ライブラリ横断の設計比較は Claude Codeで認証/認可を実装する実践ガイド にまとめてあります。apply_migration はファイル管理を CLI と別系統にするため、PR レビューで差分が追えなくなります。マイグレーション本体は常に supabase/migrations/ 配下にファイルとして置き、Claude Code には「そのファイルを読んで SQL を書き足す」タスクに限定させるのが安全です。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 × データベース開発完全ガイド もあわせてご覧ください。
