Supabase の DB 操作は supabase-js を通じた PostgREST へのリクエストとして行われる。スキーマ変更は SQL ファイルで管理し、db diff → db push のフローで本番に反映する。
supabase.from('table').select/insert/update/delete が基本パターンdb push で本番へ適用するsupabase-js は内部的に PostgREST への HTTP リクエスト。SQL を直接書いているわけではないsupabase.from() は PostgREST への HTTP リクエストを生成するビルダー。SQL ではなく HTTP で DB を操作している。
supabase.from('posts').select('*')
→ GET https://<project>.supabase.co/rest/v1/posts
Headers: apikey, Authorization(JWT)
RLS が有効な場合、JWT に含まれる auth.uid() がポリシーの判定に自動で使われる。
service_role_key を使うとこの RLS をバイパスできる(サーバーサイド限定で使用)。
.select() の引数でカラム指定・リレーション取得・集計を制御する。
// 全カラム取得
const { data, error } = await supabase
.from('posts')
.select('*')
// カラム指定
const { data } = await supabase
.from('posts')
.select('id, title, created_at')
// 単一行取得(存在しない場合は error になる)
const { data } = await supabase
.from('posts')
.select('*')
.eq('id', postId)
.single()
// posts に紐づく profiles(author)を一緒に取得
const { data } = await supabase
.from('posts')
.select(`
id,
title,
profiles (
id,
username,
avatar_url
)
`)
// 多対多(中間テーブル経由)
const { data } = await supabase
.from('posts')
.select(`
id,
title,
post_tags (
tags ( name )
)
`)
💡 ポイント: PostgREST のリレーション取得は外部キー定義に基づいて自動解決される。テーブル間に外部キーがないとリレーション指定はエラーになる。
// 件数のみ取得(データは返さない)
const { count } = await supabase
.from('posts')
.select('*', { count: 'exact', head: true })
フィルタはメソッドチェーンで重ねられる。ページネーションは .range() で行番号を指定する。
.eq('status', 'published') // status = 'published'
.neq('status', 'deleted') // status != 'deleted'
.gt('age', 18) // age > 18
.gte('age', 18) // age >= 18
.lt('price', 1000) // price < 1000
.lte('price', 1000) // price <= 1000
.like('title', '%Next.js%') // LIKE(大文字小文字区別)
.ilike('title', '%next.js%') // ILIKE(大文字小文字無視)
.in('status', ['draft', 'published']) // IN句
.is('deleted_at', null) // IS NULL
.not('deleted_at', 'is', null) // IS NOT NULL
const PAGE_SIZE = 20
const { data } = await supabase
.from('posts')
.select('id, title, created_at')
.eq('status', 'published')
.order('created_at', { ascending: false }) // 新しい順
.range(page * PAGE_SIZE, (page + 1) * PAGE_SIZE - 1) // ページネーション
💡 ポイント:
.range(from, to)は 0 始まりの行番号。1ページ目なら.range(0, 19)で20件取得。
// PostgreSQL の全文検索(tsvector / tsquery)
const { data } = await supabase
.from('posts')
.select('id, title')
.textSearch('title', 'Next.js Supabase', {
type: 'websearch', // OR/AND などのウェブ検索風クエリを解釈
config: 'english',
})
書き込み系はRLSの WITH CHECK ポリシーが適用される。エラーハンドリングを必ず実装する。
// 単一行
const { data, error } = await supabase
.from('posts')
.insert({
title: 'Hello World',
body: 'Content here',
user_id: user.id,
})
.select() // 挿入した行を返す(省略するとdataはnull)
.single()
// 複数行
const { data, error } = await supabase
.from('posts')
.insert([
{ title: 'Post 1', user_id: user.id },
{ title: 'Post 2', user_id: user.id },
])
.select()
// 条件を必ず指定する(指定しないと全行更新になる)
const { data, error } = await supabase
.from('posts')
.update({ title: 'Updated Title', updated_at: new Date().toISOString() })
.eq('id', postId)
.eq('user_id', user.id) // RLSがあっても明示的に条件を追加するのが安全
.select()
.single()
// id が存在すればUPDATE、なければINSERT
const { data, error } = await supabase
.from('profiles')
.upsert({
id: user.id,
username: 'mickey',
updated_at: new Date().toISOString(),
})
.select()
.single()
const { error } = await supabase
.from('posts')
.delete()
.eq('id', postId)
💡 ポイント: UPDATE / DELETE は条件を必ず指定する。RLS があっても条件なしで呼ぶと「自分の全行」が対象になる。
.select()を付けないと操作後のデータは返ってこない(dataが null)。
supabase-js は例外を投げない。error オブジェクトで必ず確認する。
const { data, error } = await supabase
.from('posts')
.insert({ title: '', user_id: user.id }) // titleにNOT NULL制約があると失敗
if (error) {
console.error(error.code, error.message, error.details)
// error.code → PostgreSQL エラーコード(例: '23502' = NOT NULL違反)
// error.message → エラーメッセージ
// error.details → 詳細情報
}
よく遭遇するエラーコード:
| コード | 意味 | 対策 |
|---|---|---|
23502 | NOT NULL 制約違反 | 必須カラムの値を確認 |
23503 | 外部キー制約違反 | 参照先の行が存在するか確認 |
23505 | UNIQUE 制約違反 | 重複チェックを先に行う |
42501 | RLS により拒否 | ポリシーとユーザーの権限を確認 |
PGRST116 | .single() で0件または複数件 | .maybeSingle() を検討 |
💡 ポイント:
.single()は結果が0件・2件以上どちらでもエラーになる。「存在しない可能性がある」クエリには.maybeSingle()を使う(結果なしの場合data: null、エラーなし)。
Supabase CLI で型定義を自動生成すると、テーブルのスキーマに合った型補完が効く。
# 型定義ファイルを自動生成
npx supabase gen types typescript --project-id <project-ref> > types/supabase.ts
# ローカル環境から生成(supabase start が起動済みの場合)
npx supabase gen types typescript --local > types/supabase.ts
// lib/supabase/server.ts に型を渡す
import { Database } from '@/types/supabase'
import { createServerClient } from '@supabase/ssr'
export const createClient = async () => {
const cookieStore = await cookies()
return createServerClient<Database>( // ← ジェネリクスで型を渡す
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { ... } }
)
}
// 使うと自動的に型がつく
const { data } = await supabase.from('posts').select('id, title')
// data: { id: string; title: string }[] | null
💡 ポイント: スキーマを変更したら
gen typesを再実行する。CI/CD に組み込んで自動更新するのが理想。
スキーマ変更は必ず migration ファイルとして管理する。GUI で変えた内容も db diff で拾える。
ローカルDB(Docker)でスキーマ変更
↓ supabase db diff
migration ファイル自動生成(SQL)
↓ git add / commit
Git リポジトリで履歴管理
↓ supabase db push
本番 DB に適用
# ローカル環境を起動(Docker 必須)
supabase start
# 本番プロジェクトと紐づけ(初回のみ)
supabase link --project-ref <project-ref>
# スキーマ差分から migration ファイルを自動生成
supabase db diff -f add_posts_table
# → supabase/migrations/20240101120000_add_posts_table.sql が生成される
# migration ファイルを手動作成(空ファイル)
supabase migration new add_comments_table
# ローカルDBにすべての migration を適用(リセット)
supabase db reset
# 本番DBに未適用の migration を適用
supabase db push
# 本番DBのスキーマをローカルに取り込む(既存プロジェクトに途中参加する場合)
supabase db pull
-- supabase/migrations/20240101120000_add_posts_table.sql
CREATE TABLE posts (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
title TEXT NOT NULL,
body TEXT,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- RLS を必ず有効化
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- インデックス(RLS ポリシーで参照するカラムに必要)
CREATE INDEX idx_posts_user_id ON posts(user_id);
CREATE INDEX idx_posts_status ON posts(status);
supabase/
├── config.toml # ローカル設定
├── migrations/
│ ├── 20240101_init.sql
│ ├── 20240110_add_posts_table.sql
│ └── 20240120_add_comments_table.sql
└── seed.sql # ローカル開発用の初期データ
💡 ポイント: GUI(Table Editor)でテーブルを作った後に
db diffを実行しても差分を拾える。「まず GUI で試して、動いたら diff で SQL に落とす」順番も有効。migration ファイルは一度 push したら編集しない。修正が必要なら新しい migration を追加する。
-- supabase/seed.sql(supabase db reset のたびに自動実行)
INSERT INTO posts (title, body, user_id, status)
VALUES
('テスト投稿1', 'ローカル開発用のサンプルデータ', '00000000-0000-0000-0000-000000000001', 'published'),
('テスト投稿2', 'ドラフト記事のサンプル', '00000000-0000-0000-0000-000000000001', 'draft');
💡 ポイント:
seed.sqlはsupabase db reset時に自動実行されるが、db pushでは実行されない。本番に seed データが流れる心配はない。
| ミス | 影響 | 対策 |
|---|---|---|
.select() を省略して INSERT/UPDATE | data が null で返る | 書き込み後のデータが必要な場合は .select() を追加 |
.single() を「存在しない可能性あり」のクエリに使う | 0件の時にエラーになる | .maybeSingle() を使う |
| UPDATE / DELETE に条件を指定しない | RLS があっても自分の全行が対象になる | 必ず .eq('id', id) 等の条件を付ける |
| migration ファイルを後から編集する | 適用済みの migration を変えると環境間で乖離が起きる | 修正は新しい migration として追加する |
| スキーマ変更後に型定義を再生成しない | TypeScript の型が古いままになる | 変更のたびに gen types を再実行する |
| RLS を設定せずにテーブルを公開する | 全ユーザーが全データにアクセスできる | テーブル作成と同時に RLS を有効化する |