RLS を有効にした翌朝、一覧が 4 秒かかっていた
個人開発で運用しているアプリのバックエンドを Supabase に寄せたとき、最初につまずいたのは認証でもストレージでもありませんでした。RLS(行レベルセキュリティ)を全テーブルに有効化した翌朝、それまで 80ms 前後で返っていた一覧クエリが、ローカルの検証データですら 4 秒近くかかるようになっていたのです。
ポリシーの書き方は Supabase の公式ドキュメントどおりでした。インデックスも張ってあります。それでも遅いままでした。原因にたどり着くまで、半日ほど EXPLAIN ANALYZE の出力とにらみ合うことになりました。
Supabase は、PostgreSQL をコアに認証・ストレージ・Edge Functions・Realtime を統合した、よくできた基盤です。個人開発者がひとりで負いきれないバックエンドの重さを、驚くほど軽くしてくれます。Antigravity と組み合わせれば、スキーマ設計から RLS ポリシーの生成、Edge Functions の実装まで、プロジェクトの文脈を踏まえたまま一気に進みます。
ただ、生成されたコードがそのまま本番に耐えるかは別の話です。動くポリシーと、速くて安全なポリシーのあいだには、はっきりした距離があります。
以下では Auth・RLS・Edge Functions・Realtime・Storage の実装パターンを一通り追いながら、その距離を埋めるために私自身が踏んだ判断——RLS の性能設計と、個人開発で費用が跳ねる境目——まで含めて書いております。冒頭の 4 秒がどこへ消えたかは、第 3 章のあとで扱います。
第1章:プロジェクトセットアップと Antigravity の初期設定
Supabase プロジェクトの準備
まず Supabase CLI をインストールし、ローカル開発環境を整えます。Antigravity のターミナルで以下を実行してください。
# Supabase CLI のインストール
npm install supabase --save-dev
# プロジェクトの初期化
npx supabase init
# ローカル Supabase の起動(Docker が必要)
npx supabase start
起動後、以下の情報が表示されます。
Started supabase local development setup.
API URL: http://127.0.0.1:54321
GraphQL URL: http://127.0.0.1:54321/graphql/v1
S3 Storage URL: http://127.0.0.1:54321/storage/v1/s3
DB URL: postgresql://postgres:postgres@127.0.0.1:54322/postgres
Studio URL: http://127.0.0.1:54323
Inbucket URL: http://127.0.0.1:54324
JWT secret: super-secret-jwt-token-with-at-least-32-characters-long
anon key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
service_role key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Antigravity の RULES.md 設定
Antigravity がプロジェクトの文脈を正確に理解できるよう、RULES.md(または .antigravity/rules.md)を設定します。
# Project Rules
## Tech Stack
- Next.js 15+ App Router
- Supabase (PostgreSQL + Auth + Storage + Realtime)
- TypeScript(strict モード)
- Tailwind CSS
## Supabase Conventions
- テーブル名: snake_case(複数形)
- カラム名: snake_case
- RLS は全テーブルに必ず有効化すること
- service_role キーはサーバーサイドのみ使用
## Security Rules
- クライアントサイドでは anon key のみ使用
- ユーザーデータへのアクセスは必ず RLS で制御
- Edge Functions 内でのみ service_role を使用
この設定があることで、Antigravity はコード生成時に自動的にセキュリティベストプラクティスを適用してくれます。
TypeScript クライアントの設定
// lib/supabase/client.ts — クライアントコンポーネント用
import { createBrowserClient } from '@supabase/ssr'
import type { Database } from '@/types/supabase'
export function createClient () {
return createBrowserClient < Database >(
process.env. NEXT_PUBLIC_SUPABASE_URL ! ,
process.env. NEXT_PUBLIC_SUPABASE_ANON_KEY !
)
}
// lib/supabase/server.ts — サーバーコンポーネント / Server Actions 用
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import type { Database } from '@/types/supabase'
export async function createClient () {
const cookieStore = await cookies ()
return createServerClient < Database >(
process.env. NEXT_PUBLIC_SUPABASE_URL ! ,
process.env. NEXT_PUBLIC_SUPABASE_ANON_KEY ! ,
{
cookies: {
getAll () {
return cookieStore. getAll ()
},
setAll ( cookiesToSet ) {
try {
cookiesToSet. forEach (({ name , value , options }) =>
cookieStore. set (name, value, options)
)
} catch {
// Server Components からの呼び出しでは無視可能
}
},
},
}
)
}
Antigravity にこのファイルを見せておくと、以降のコード生成でクライアントの使い分けを自動的に正確に行ってくれます。
第2章:Supabase Auth の完全実装
認証フローの設計
Supabase Auth は JWT ベースの認証を提供し、メール/パスワード、マジックリンク、OAuth(Google, GitHub 等)に対応しています。Next.js App Router での推奨パターンを解説します。
// app/auth/callback/route.ts — OAuth コールバック
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET ( request : Request ) {
const { searchParams , origin } = new URL (request.url)
const code = searchParams. get ( 'code' )
const next = searchParams. get ( 'next' ) ?? '/'
if (code) {
const supabase = await createClient ()
const { error } = await supabase.auth. exchangeCodeForSession (code)
if ( ! error) {
return NextResponse. redirect ( `${ origin }${ next }` )
}
}
// エラー時はエラーページへ
return NextResponse. redirect ( `${ origin }/auth/error` )
}
ミドルウェアでのセッション管理
// middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware ( request : NextRequest ) {
let supabaseResponse = NextResponse. next ({ request })
const supabase = createServerClient (
process.env. NEXT_PUBLIC_SUPABASE_URL ! ,
process.env. NEXT_PUBLIC_SUPABASE_ANON_KEY ! ,
{
cookies: {
getAll () {
return request.cookies. getAll ()
},
setAll ( cookiesToSet ) {
cookiesToSet. forEach (({ name , value }) =>
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 ( '/dashboard' )) {
const url = request.nextUrl. clone ()
url.pathname = '/login'
return NextResponse. redirect (url)
}
return supabaseResponse
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.* \\ .(?:svg|png|jpg|jpeg|gif|webp)$).*)' ,
],
}
Antigravity はこのミドルウェアパターンを理解した上で、「ダッシュボードに認証ガードを追加して」という指示だけで適切な設定を自動生成できます。
プロフィール拡張パターン
auth.users テーブルを直接変更できないため、プロフィール情報は public.profiles テーブルで管理するのが定石です。
-- supabase/migrations/20260404_create_profiles.sql
-- プロフィールテーブルの作成
create table public .profiles (
id uuid references auth . users on delete cascade not null primary key ,
username text unique ,
full_name text ,
avatar_url text ,
subscription_tier text default 'free' check (subscription_tier in ( 'free' , 'pro' , 'premium' )),
created_at timestamptz default now () not null ,
updated_at timestamptz default now () not null
);
-- RLS の有効化(必須)
alter table public . profiles enable row level security ;
-- ポリシー:本人のみ参照可能
create policy "Users can view own profile"
on public . profiles
for select
using ( auth . uid () = id);
-- ポリシー:本人のみ更新可能
create policy "Users can update own profile"
on public . profiles
for update
using ( auth . uid () = id);
-- ユーザー作成時に自動でプロフィールを生成するトリガー
create or replace function public .handle_new_user()
returns trigger as $$
begin
insert into public . profiles (id, full_name, avatar_url)
values (
new . id ,
new . raw_user_meta_data ->> 'full_name' ,
new . raw_user_meta_data ->> 'avatar_url'
);
return new;
end ;
$$ language plpgsql security definer;
create trigger on_auth_user_created
after insert on auth . users
for each row execute procedure public . handle_new_user ();
Antigravity にこのマイグレーションファイルを作成させる場合、「新規ユーザー登録時に自動でプロフィールを作成し、RLS でセキュアに管理するスキーマを作って」と指示するだけで、上記と同等のコードを生成してくれます。
第3章:RLS(行レベルセキュリティ)の実践パターン
RLS が重要な理由
RLS は Supabase セキュリティの核心です。データベースレベルでアクセス制御を行うため、アプリケーションコードのバグによるデータ漏洩リスクを根本から防げます。
Antigravity を使った RLS 設計では、まず「どのユーザーがどのデータにアクセスできるか」を自然言語で伝えると、適切なポリシーを生成してくれます。
マルチテナント SaaS のための RLS パターン
-- supabase/migrations/20260404_create_teams.sql
-- チームテーブル
create table public .teams (
id uuid default gen_random_uuid() primary key ,
name text not null ,
owner_id uuid references auth . users not null ,
plan text default 'free' check (plan in ( 'free' , 'pro' , 'enterprise' )),
created_at timestamptz default now () not null
);
-- チームメンバーテーブル(中間テーブル)
create table public .team_members (
team_id uuid references public . teams on delete cascade not null ,
user_id uuid references auth . users on delete cascade not null ,
role text default 'member' check ( role in ( 'owner' , 'admin' , 'member' )),
joined_at timestamptz default now () not null ,
primary key (team_id, user_id)
);
-- プロジェクトテーブル(チーム所属)
create table public .projects (
id uuid default gen_random_uuid() primary key ,
team_id uuid references public . teams on delete cascade not null ,
name text not null ,
description text ,
status text default 'active' check ( status in ( 'active' , 'archived' )),
created_at timestamptz default now () not null ,
updated_at timestamptz default now () not null
);
-- RLS の有効化
alter table public . teams enable row level security ;
alter table public . team_members enable row level security ;
alter table public . projects enable row level security ;
-- ヘルパー関数:チームメンバーかどうかを確認
create or replace function public .is_team_member(team_id uuid)
returns boolean as $$
select exists (
select 1 from public . team_members
where team_members . team_id = $ 1
and team_members . user_id = auth . uid ()
)
$$ language sql security definer stable;
-- ヘルパー関数:チーム管理者かどうかを確認
create or replace function public .is_team_admin(team_id uuid)
returns boolean as $$
select exists (
select 1 from public . team_members
where team_members . team_id = $ 1
and team_members . user_id = auth . uid ()
and team_members . role in ( 'owner' , 'admin' )
)
$$ language sql security definer stable;
-- teams ポリシー
create policy "Team members can view their teams"
on public . teams for select
using ( public . is_team_member (id));
create policy "Team owners can update team"
on public . teams for update
using (owner_id = auth . uid ());
-- projects ポリシー
create policy "Team members can view projects"
on public . projects for select
using ( public . is_team_member (team_id));
create policy "Team admins can create projects"
on public . projects for insert
with check ( public . is_team_admin (team_id));
create policy "Team admins can update projects"
on public . projects for update
using ( public . is_team_admin (team_id));
RLS と TypeScript の型安全な連携
// lib/supabase/queries/projects.ts
import { createClient } from '@/lib/supabase/server'
import type { Database } from '@/types/supabase'
type Project = Database [ 'public' ][ 'Tables' ][ 'projects' ][ 'Row' ]
export async function getProjectsByTeam ( teamId : string ) : Promise < Project []> {
const supabase = await createClient ()
const { data , error } = await supabase
. from ( 'projects' )
. select ( `
*,
team:teams(name, plan)
` )
. eq ( 'team_id' , teamId)
. eq ( 'status' , 'active' )
. order ( 'created_at' , { ascending: false })
if (error) {
console. error ( 'Failed to fetch projects:' , error)
throw new Error (error.message)
}
// RLS により、このクエリは自動的に認証済みユーザーが
// アクセス権を持つプロジェクトのみを返す
return data
}
第4章:Supabase Edge Functions の活用
Edge Functions が輝くユースケース
Edge Functions は Deno ランタイム上で動作するサーバーサイドロジックです。以下のケースで特に有効です。
Stripe Webhook の受信と処理
OpenAI / Gemini API の呼び出し(API キーを隠蔽)
複雑なビジネスロジックの実行
サードパーティ API の統合
Antigravity で Edge Function を生成する
Antigravity に「Stripe の Webhook を受信して、支払い完了時にユーザープランを更新する Edge Function を作って」と伝えると、以下のような関数を生成します。
// supabase/functions/stripe-webhook/index.ts
import Stripe from 'npm:stripe@14'
import { createClient } from 'npm:@supabase/supabase-js@2'
const stripe = new Stripe (Deno.env. get ( 'STRIPE_SECRET_KEY' ) ! , {
httpClient: Stripe. createFetchHttpClient (),
})
const supabase = createClient (
Deno.env. get ( 'SUPABASE_URL' ) ! ,
Deno.env. get ( 'SUPABASE_SERVICE_ROLE_KEY' ) ! // Edge Functions では service_role を使用可能
)
Deno. serve ( async ( req ) => {
const signature = req.headers. get ( 'stripe-signature' ) !
const body = await req. text ()
let event : Stripe . Event
try {
event = await stripe.webhooks. constructEventAsync (
body,
signature,
Deno.env. get ( 'STRIPE_WEBHOOK_SECRET' ) !
)
} catch (err) {
console. error ( 'Webhook signature verification failed:' , err)
return new Response ( 'Invalid signature' , { status: 400 })
}
if (event.type === 'checkout.session.completed' ) {
const session = event.data.object as Stripe . Checkout . Session
const userId = session.metadata?.user_id
const plan = session.metadata?.plan
if (userId && plan) {
const { error } = await supabase
. from ( 'profiles' )
. update ({
subscription_tier: plan,
updated_at: new Date (). toISOString (),
})
. eq ( 'id' , userId)
if (error) {
console. error ( 'Failed to update subscription:' , error)
return new Response ( 'Database error' , { status: 500 })
}
}
}
if (event.type === 'customer.subscription.deleted' ) {
const subscription = event.data.object as Stripe . Subscription
const userId = subscription.metadata?.user_id
if (userId) {
await supabase
. from ( 'profiles' )
. update ({ subscription_tier: 'free' , updated_at: new Date (). toISOString () })
. eq ( 'id' , userId)
}
}
return new Response ( JSON . stringify ({ received: true }), {
headers: { 'Content-Type' : 'application/json' },
})
})
Edge Function のデプロイと管理
# ローカルでの Edge Function 起動
npx supabase functions serve stripe-webhook --env-file .env.local
# 本番へのデプロイ
npx supabase functions deploy stripe-webhook --no-verify-jwt
# シークレットの設定
npx supabase secrets set STRIPE_SECRET_KEY=sk_live_...
npx supabase secrets set STRIPE_WEBHOOK_SECRET=whsec_...
第5章:Realtime チャンネルによるリアルタイム機能
Supabase Realtime の仕組み
Supabase Realtime は WebSocket を使った変更通知システムです。PostgreSQL の論理レプリケーションを活用し、データベースの変更をリアルタイムでクライアントに伝達します。3つのモードがあります。
Broadcast : クライアント間での低レイテンシメッセージ配信
Presence : オンラインユーザーの状態管理
Postgres Changes : データベースの変更をリアルタイムに受信
イベントが来ないときに見る場所は、2 つに絞れます
Realtime を組んで最初に出会うのは、たいてい「エラーは出ていないのに、イベントだけが来ない」という状態です。私も購読側のコードを何度も書き直したあとで、原因がクライアントになかったことに気づきました。
1 つ目は、そのテーブルが supabase_realtime パブリケーションに登録されているかどうかです。Postgres Changes は論理レプリケーションの上に乗っているため、登録していないテーブルの変更は Realtime まで届きません。
-- 購読したいテーブルを明示的に登録します
alter publication supabase_realtime add table public . messages ;
-- いま登録されているテーブルを確認します
select schemaname, tablename
from pg_publication_tables
where pubname = 'supabase_realtime' ;
2 つ目は RLS です。Postgres Changes は、購読しているユーザーがその行を select できるときにだけ配信します。つまり第 3 章で書いたポリシーが、そのまま配信の可否を決めています。ポリシーを厳しくした直後に通知が止まったなら、疑うべきは購読コードよりもポリシーの方です。
ただし DELETE にはポリシーが適用されません。削除済みの行に対して権限を確かめる手段が Postgres 側にないためです。さらに DELETE をフィルタで絞りたい場合は、テーブルの replica identity を full にしておく必要があります。
alter table public . messages replica identity full;
購読者の数だけ、ポリシーの遅さが掛け算されます
ここが、第 3 章の RLS と第 5 章の Realtime がつながる場所です。
Postgres Changes は、1 件の変更をすべての購読者に対して個別に認可します。100 人が同じテーブルを購読していれば、1 回の insert で認可は 100 回。しかも変更の順序を保つために単一スレッドで処理されるため、上位のコンピュートへ上げてもスループットはあまり伸びません。公式ドキュメントは、同時購読がおよそ 3,000 を超える見込みであれば Postgres Changes ではなく Broadcast へ寄せることを勧めています。
言い換えると、遅いポリシーの代償は一覧クエリの応答時間だけでは終わらないのです。購読者の数だけ掛け算され、通知の遅れとして返ってきます。この記事の後半で扱う (select auth.uid()) の書き換えは、画面の表示を速くするためだけの工夫ではありません。
手前でできることは 2 つあります。購読範囲をフィルタで絞ることと、送る列そのものを絞ることです。
// 必要な列だけを受け取る(payload.new は { id, status } だけになります)
const channel = supabase
. channel ( 'jobs-status' )
. on (
'postgres_changes' ,
{
event: 'UPDATE' ,
schema: 'public' ,
table: 'jobs' ,
filter: `owner_id=eq.${ userId }` ,
select: [ 'id' , 'status' ],
},
( payload ) => console. log (payload.new)
)
. subscribe ()
select を添えるとペイロードが主キーと指定列だけになります。jsonb や長文のカラムを持つテーブルでは、これだけで転送量がはっきり落ちます——そしてその転送量が、後半で触れる費用の話にそのままつながります。
AI チャットアプリへの応用
Antigravity を使って AI チャット機能を実装する際、Realtime を使うことでストリーミングレスポンスをエレガントに処理できます。
// hooks/useRealtimeChat.ts
'use client'
import { useState, useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import type { RealtimeChannel } from '@supabase/supabase-js'
interface Message {
id : string
content : string
role : 'user' | 'assistant'
created_at : string
}
export function useRealtimeChat ( conversationId : string ) {
const [ messages , setMessages ] = useState < Message []>([])
const [ isStreaming , setIsStreaming ] = useState ( false )
const supabase = createClient ()
useEffect (() => {
// 既存メッセージの取得
const fetchMessages = async () => {
const { data } = await supabase
. from ( 'messages' )
. select ( '*' )
. eq ( 'conversation_id' , conversationId)
. order ( 'created_at' , { ascending: true })
if (data) setMessages (data)
}
fetchMessages ()
// Realtime サブスクリプションの設定
const channel : RealtimeChannel = supabase
. channel ( `conversation:${ conversationId }` )
. on (
'postgres_changes' ,
{
event: 'INSERT' ,
schema: 'public' ,
table: 'messages' ,
filter: `conversation_id=eq.${ conversationId }` ,
},
( payload ) => {
const newMessage = payload.new as Message
setMessages (( prev ) => [ ... prev, newMessage])
if (newMessage.role === 'assistant' ) {
setIsStreaming ( false )
}
}
)
. subscribe ()
return () => {
supabase. removeChannel (channel)
}
}, [conversationId, supabase])
const sendMessage = useCallback ( async ( content : string ) => {
setIsStreaming ( true )
// ユーザーメッセージを DB に保存
await supabase. from ( 'messages' ). insert ({
conversation_id: conversationId,
content,
role: 'user' ,
})
// Edge Function を呼び出して AI レスポンスを生成
await supabase.functions. invoke ( 'ai-chat' , {
body: { conversation_id: conversationId, message: content },
})
}, [conversationId, supabase])
return { messages, isStreaming, sendMessage }
}
Presence でオンライン状態を管理
// hooks/usePresence.ts
'use client'
import { useState, useEffect } from 'react'
import { createClient } from '@/lib/supabase/client'
interface UserPresence {
user_id : string
username : string
online_at : string
}
export function usePresence ( roomId : string , currentUser : { id : string ; name : string }) {
const [ onlineUsers , setOnlineUsers ] = useState < UserPresence []>([])
const supabase = createClient ()
useEffect (() => {
const channel = supabase. channel ( `presence:${ roomId }` , {
config: {
presence: {
key: currentUser.id,
},
},
})
channel
. on ( 'presence' , { event: 'sync' }, () => {
const state = channel. presenceState < UserPresence >()
const users = Object. values (state). flat ()
setOnlineUsers (users)
})
. subscribe ( async ( status ) => {
if (status === 'SUBSCRIBED' ) {
await channel. track ({
user_id: currentUser.id,
username: currentUser.name,
online_at: new Date (). toISOString (),
})
}
})
return () => {
supabase. removeChannel (channel)
}
}, [roomId, currentUser, supabase])
return { onlineUsers }
}
第6章:Supabase Storage の活用パターン
ファイルアップロードとアクセス制御
// app/actions/upload.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
export async function uploadFile ( formData : FormData ) {
const supabase = await createClient ()
const {
data : { user },
} = await supabase.auth. getUser ()
if ( ! user) {
return { error: '認証が必要です' }
}
const file = formData. get ( 'file' ) as File
if ( ! file) {
return { error: 'ファイルが選択されていません' }
}
// ファイルサイズ制限(5MB)
if (file.size > 5 * 1024 * 1024 ) {
return { error: 'ファイルサイズは5MB以下にしてください' }
}
// ユーザーごとのディレクトリに保存
const fileName = `${ Date . now () }-${ file . name . replace ( / [ ^ a-zA-Z0-9.-] / g , '_' ) }`
const filePath = `${ user . id }/${ fileName }`
const { error } = await supabase.storage
. from ( 'user-uploads' )
. upload (filePath, file, {
cacheControl: '3600' ,
upsert: false ,
})
if (error) {
console. error ( 'Upload error:' , error)
return { error: 'アップロードに失敗しました' }
}
// ファイルメタデータを DB に保存
await supabase. from ( 'files' ). insert ({
user_id: user.id,
file_path: filePath,
file_name: file.name,
file_size: file.size,
mime_type: file.type,
})
revalidatePath ( '/dashboard/files' )
return { success: true , path: filePath }
}
export async function getSignedUrl ( filePath : string ) {
const supabase = await createClient ()
// 署名付き URL(1時間有効)
const { data , error } = await supabase.storage
. from ( 'user-uploads' )
. createSignedUrl (filePath, 3600 )
if (error) return { error: error.message }
return { url: data.signedUrl }
}
Storage のバケットポリシー設定
-- storage バケットの RLS ポリシー(Supabase Studio または マイグレーションで設定)
-- ユーザーは自分のファイルのみアップロード可能
create policy "Users can upload own files"
on storage . objects for insert
to authenticated
with check (
bucket_id = 'user-uploads'
and ( storage . foldername ( name ))[1] = auth . uid ():: text
);
-- ユーザーは自分のファイルのみ参照可能
create policy "Users can view own files"
on storage . objects for select
to authenticated
using (
bucket_id = 'user-uploads'
and ( storage . foldername ( name ))[1] = auth . uid ():: text
);
-- ユーザーは自分のファイルのみ削除可能
create policy "Users can delete own files"
on storage . objects for delete
to authenticated
using (
bucket_id = 'user-uploads'
and ( storage . foldername ( name ))[1] = auth . uid ():: text
);
第7章:型生成と開発体験の向上
Supabase の型自動生成
Supabase CLI を使って TypeScript の型定義を自動生成できます。これにより、データベーススキーマの変更がコンパイル時に検出できます。
# ローカル環境からの型生成
npx supabase gen types typescript --local > types/supabase.ts
# 本番環境からの型生成(プロジェクト ID が必要)
npx supabase gen types typescript \
--project-id YOUR_PROJECT_ID \
--schema public > types/supabase.ts
生成された型定義を活用することで、クエリの誤りをコンパイル時に検出できます。
// 型安全なクエリの例
import type { Database } from '@/types/supabase'
type Tables = Database [ 'public' ][ 'Tables' ]
type Profile = Tables [ 'profiles' ][ 'Row' ]
type ProfileUpdate = Tables [ 'profiles' ][ 'Update' ]
// ✅ 型チェックが効く
const update : ProfileUpdate = {
full_name: '新しい名前' ,
updated_at: new Date (). toISOString (),
}
// ❌ 存在しないカラムはコンパイルエラー
// const invalid: ProfileUpdate = { nonexistent_field: 'value' }
package.json へのスクリプト追加
{
"scripts" : {
"db:types" : "supabase gen types typescript --local > types/supabase.ts" ,
"db:migrate" : "supabase db push" ,
"db:reset" : "supabase db reset" ,
"db:seed" : "npx supabase db seed"
}
}
Antigravity はこのスクリプトを把握した上で、「型を更新して新機能を実装して」という指示に対して、適切な順序でコマンドと実装を提案してくれます。
第8章:本番デプロイと監視
環境変数の管理
# .env.local(ローカル開発用)
NEXT_PUBLIC_SUPABASE_URL = http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY = your-anon-key
SUPABASE_SERVICE_ROLE_KEY = your-service-role-key
# Vercel / Cloudflare Workers での設定
# → ダッシュボードまたは CLI で設定
Supabase のパフォーマンスチューニング
-- よく使うクエリにインデックスを追加
create index idx_messages_conversation_id
on public . messages (conversation_id, created_at desc );
create index idx_projects_team_id
on public . projects (team_id, status , created_at desc );
-- pg_stat_statements で遅いクエリを特定
select query, mean_exec_time, calls
from pg_stat_statements
order by mean_exec_time desc
limit 10 ;
Supabase Logs の活用
// lib/supabase/logger.ts
export async function logEvent (
event : string ,
metadata : Record < string , unknown >
) {
const supabase = await createClient ()
await supabase. from ( 'audit_logs' ). insert ({
event,
metadata,
created_at: new Date (). toISOString (),
})
}
第9章:CI/CD と統合テスト
マイグレーションを手元から追い出す
手元のターミナルから supabase db push を叩く運用は、本番が動き出すと最初に破綻します。私の場合は、当てたつもりのマイグレーションが本番に入っておらず、原因を探して午前中をまるごと使ったことが移行のきっかけでした。
# .github/workflows/deploy.yml
name : Deploy to Production
on :
push :
branches : [ main ]
jobs :
migrate-and-deploy :
runs-on : ubuntu-latest
env :
SUPABASE_ACCESS_TOKEN : ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_DB_PASSWORD : ${{ secrets.SUPABASE_DB_PASSWORD }}
PROJECT_ID : ${{ secrets.SUPABASE_PROJECT_ID }}
steps :
- uses : actions/checkout@v4
- name : Setup Supabase CLI
uses : supabase/setup-cli@v1
with :
version : latest
- name : Link Supabase project
run : supabase link --project-ref $PROJECT_ID
- name : Run database migrations
run : supabase db push
# --no-verify-jwt は署名で本人確認できる Webhook だけに限定します。
# 関数名を省いて deploy すると、全 Edge Function の JWT 検証が外れます。
- name : Deploy webhook function
run : supabase functions deploy stripe-webhook --no-verify-jwt
- name : Deploy authenticated functions
run : supabase functions deploy ai-chat
--no-verify-jwt の行は、第 4 章で触れた Stripe Webhook のためのものです。Webhook は Supabase の JWT を持たない相手から届くので検証を外す必要がありますが、代わりに署名で本人確認をしています。ここで関数名を省くと、署名検証を持たない関数まで無認証で開いてしまいます。外すのは検証そのものではなく、検証の担当者だけです。
テストは本物のローカル DB に当てる
RLS の不具合は、モックしたクライアントでは絶対に見つかりません。ポリシーを評価しているのは Postgres であって、クライアントライブラリではないからです。統合テストは supabase start で立てたローカル DB に当てます。
// tests/rls.test.ts
import { describe, it, expect, beforeAll } from 'vitest'
import { createClient } from '@supabase/supabase-js'
const URL = 'http://127.0.0.1:54321'
const admin = createClient ( URL , process.env. SUPABASE_SERVICE_ROLE_KEY ! )
// 別チームのユーザーとして読み、0 件で返ることを確かめます
describe ( 'projects RLS' , () => {
let outsiderToken : string
let foreignProjectId : string
beforeAll ( async () => {
const { data : owner } = await admin.auth.admin. createUser ({
email: 'owner@example.test' , password: 'YOUR_TEST_PASSWORD' , email_confirm: true ,
})
const { data : outsider } = await admin.auth.admin. createUser ({
email: 'outsider@example.test' , password: 'YOUR_TEST_PASSWORD' , email_confirm: true ,
})
const { data : team } = await admin
. from ( 'teams' ). insert ({ name: 'Owner Team' , owner_id: owner.user ! .id }). select (). single ()
await admin. from ( 'team_members' ). insert ({ team_id: team ! .id, user_id: owner.user ! .id, role: 'owner' })
const { data : project } = await admin
. from ( 'projects' ). insert ({ team_id: team ! .id, name: 'Secret' }). select (). single ()
foreignProjectId = project ! .id
const anon = createClient ( URL , process.env. NEXT_PUBLIC_SUPABASE_ANON_KEY ! )
const { data : session } = await anon.auth. signInWithPassword ({
email: 'outsider@example.test' , password: 'YOUR_TEST_PASSWORD' ,
})
outsiderToken = session.session ! .access_token
})
it ( '所属していないチームのプロジェクトは 1 件も返らない' , async () => {
const asOutsider = createClient ( URL , process.env. NEXT_PUBLIC_SUPABASE_ANON_KEY ! , {
global: { headers: { Authorization: `Bearer ${ outsiderToken }` } },
})
const { data } = await asOutsider. from ( 'projects' ). select ( 'id' )
expect (data). toEqual ([])
})
it ( '直接 id を指定しても取れない' , async () => {
const asOutsider = createClient ( URL , process.env. NEXT_PUBLIC_SUPABASE_ANON_KEY ! , {
global: { headers: { Authorization: `Bearer ${ outsiderToken }` } },
})
const { data } = await asOutsider. from ( 'projects' ). select ( 'id' ). eq ( 'id' , foreignProjectId)
expect (data). toEqual ([])
})
})
2 つ目のテストを足しているのには理由があります。一覧が空で返ることだけを確かめていると、eq('id', ...) のように条件を指定した経路でポリシーが抜けていても気づけません。私は実際に、一覧だけを見て安心していた時期がありました。
Antigravity にテストを書かせるときも、RULES.md に「Supabase の統合テストはモックせずローカルインスタンスに当てる」と一行入れておくと、提案がモックに寄らなくなります。
RLS を有効にした瞬間に遅くなる、その理由と直し方
冒頭で触れた 4 秒の正体は、auth.uid() の呼ばれ方でした。
using (auth.uid() = user_id) と書いたポリシーは、直感的には「1 回だけ現在のユーザー ID を取って比較する」ように読めます。しかし PostgreSQL のプランナから見ると、auth.uid() は行ごとに評価される関数呼び出しになり得ます。1 万行スキャンすれば 1 万回。JWT のパースを 1 万回繰り返していれば、4 秒もかかるはずです。
まず EXPLAIN ANALYZE で事実を見る
推測でポリシーを書き換える前に、実測します。
-- RLS はテーブル所有者に対して既定で適用されないため、
-- 検証用ロールに切り替えてから計測します
set local role authenticated;
set local request . jwt .claims = '{"sub":"00000000-0000-0000-0000-000000000001"}' ;
explain (analyze, buffers)
select id, title, updated_at
from public . projects
where team_id = '11111111-1111-1111-1111-111111111111'
order by updated_at desc
limit 20 ;
改善前の出力には、次のような行が現れていました。
Seq Scan on projects (cost=... rows=9842 width=...) (actual time=0.12..3891.44 rows=20 loops=1)
Filter: ((auth.uid() = user_id) AND (team_id = '1111...'::uuid))
Rows Removed by Filter: 9822
Seq Scan と Rows Removed by Filter: 9822 の組み合わせが、そのまま答えです。インデックスは使われず、全行に対してポリシー関数が走っています。
直し方は 3 つあり、効く順に並べられる
一段目の (select auth.uid()) は、括弧とキーワードを足すだけの変更です。ただしプランナにとっては意味が変わります。相関のないサブクエリとして扱われ、InitPlan に持ち上がり、行ごとではなくクエリごとに 1 回だけ評価されます。3,891ms が 214ms になったのは、この 1 行の書き換えだけでした。
-- 改善前
create policy "own projects" on public . projects
for select using ( auth . uid () = user_id);
-- 改善後:InitPlan 化して 1 回だけ評価させる
drop policy "own projects" on public . projects ;
create policy "own projects" on public . projects
for select using (( select auth . uid ()) = user_id);
二段目のインデックスは当たり前に見えますが、見落としがちな点があります。ポリシーの using 句に出てくる列は、アプリのクエリに書いていなくても実質的な検索条件です。user_id や team_id のように「ポリシーだけが参照する列」にこそ、インデックスが要ります。
マルチテナントで無限再帰させないための security definer
三段目は、ポリシーの中で別テーブルを引くときの話です。team_members を参照して所属を確かめるポリシーを team_members 自身にも書くと、ポリシー評価がポリシー評価を呼び、infinite recursion detected in policy で落ちます。
回避策は、参照側を RLS の外に出すことです。
-- 所属判定を security definer 関数に閉じ込める
-- (関数内のクエリは呼び出し元の RLS を受けないため再帰しない)
create or replace function public .is_team_member(target_team uuid)
returns boolean
language sql
security definer
set search_path = public -- search_path 固定は必須。省くと権限昇格の穴になる
stable
as $$
select exists (
select 1
from public . team_members
where team_id = target_team
and user_id = ( select auth . uid ())
);
$$;
revoke all on function public . is_team_member (uuid) from public;
grant execute on function public . is_team_member (uuid) to authenticated;
create policy "team projects" on public . projects
for select using ( public . is_team_member (team_id));
security definer は、うかつに使えば権限を素通しにしてしまう機能です。だからこそ set search_path = public を必ず添え、revoke all してから authenticated にだけ execute を戻します。この 2 行を省いたテンプレートを見かけますが、省いた瞬間に RLS を書いた意味が薄れます。
Antigravity に RLS ポリシーを書かせるときも、RULES.md に「ポリシー内の auth.uid() は必ず (select auth.uid()) と書く」「別テーブル参照は security definer 関数を経由する」の 2 行を足しておくと、生成される SQL がこの形に寄ります。ルールに書いていない規約は、生成物にも現れません。
変更したら、必ず同じクエリで測り直す
-- 改善後
Limit (actual time= 0 . 08 .. 9 . 14 rows= 20 loops = 1 )
-> Index Scan using projects_team_id_updated_at_idx on projects
(actual time= 0 . 07 .. 8 . 91 rows= 20 loops = 1 )
Filter : is_team_member(team_id)
Seq Scan が Index Scan に変わり、Rows Removed by Filter が消えていれば、ひとまず勝ちです。ここまで来て初めて、RLS は「安全だが遅いもの」から「安全で速いもの」になります。
個人開発で有料プランに切り替わる境目を、先に見積もっておく
Supabase の無料枠は、個人開発の入口としては十分に広いものです。ただ、どこで壁に当たるかを知らずに設計すると、想定していないタイミングで請求が立ち上がります。私自身、Realtime を安易に全テーブルへ有効化して、帯域の伸び方に驚いたことがあります。
有料プランへ押し上げる要因は、行数よりも先に別のところから来ます。
この表で言いたいのは順番です。多くの個人開発アプリでは、行数が無料枠に届くよりずっと早く、帯域と Realtime 接続が先に効いてきます。つまり最適化すべきは DB ではなく、配信と購読です。
判断の目安として、私は次のように考えております。
月々の費用が、そのアプリの収益や自分の時間単価に対して割に合っているか
有料プランに移ることで、自前でサーバを持つより安く済んでいるか
無料枠に留めるために書いている回避コードが、機能の複雑さを上回っていないか
3 つ目が特に大事です。無料枠を守るために設計をねじ曲げ始めたら、それは払うべきタイミングだと考えています。月 25 ドル前後で、認証もストレージも Realtime も込みで面倒を見てもらえるなら、私は迷わず払う側に回ります。自分の手を空けることの方が、個人開発では高くつくからです。
逆に、まだ利用者が数十人という段階で有料プランに移る必要はありません。無料枠の内側で、この記事の RLS 設計と購読の絞り込みを先に入れておけば、利用者が増えたときに慌てて作り直さずに済みます。
まとめ — 動くポリシーと、速くて安全なポリシー
Supabase は、個人開発者がひとりで抱えるには重すぎたバックエンドを、確かに軽くしてくれました。Auth・RLS・Edge Functions・Realtime・Storage を一枚の基盤として扱えることの価値は、実際に運用してみるほど効いてきます。
一方で、この記事で一番伝えたかったのは、生成されたコードや公式サンプルどおりのポリシーが、そのまま本番の速度と安全性を満たすわけではないという一点です。auth.uid() を括弧で括るかどうかで 3,891ms が 214ms になります。set search_path を書き忘れれば、RLS を書いた意味が薄れます。この差は、動かしてみるまで見えません。
Antigravity は、こちらが RULES.md に規約を書いた分だけ、その規約を守ったコードを返してくれます。逆に言えば、書かなかった規約は生成物に現れません。文脈を渡す作業は、AI に丸投げする作業ではなく、自分の設計判断を言語化する作業なのだと思っております。
次の一歩として、いま動いているプロジェクトのポリシーを 1 つだけ選び、set local role authenticated を付けて explain (analyze, buffers) を回してみてください。Seq Scan と Rows Removed by Filter が並んでいたら、この記事の 1 段目の書き換えが、そのまま効きます。
CI/CD まわりを詰めるならAntigravity × GitHub Actions — 高度な CI/CD パイプライン構築ガイド を、本番の可観測性を足すならAntigravity × OpenTelemetry — AI 可観測性パイプライン構築ガイド を、あわせてご覧いただければ幸いです。
長い記事にお付き合いいただき、ありがとうございました。