Supabase でテーブルを1つ作ると、その瞬間から HTTP で読み書きできるエンドポイントが生えています。裏側で動いているのは PostgREST という単体のオープンソース製品で、Supabase 固有の仕組みではありません。構造を知らないまま supabase-js のメソッドを並べると、入れ子の取得で関係が曖昧だと怒られ、集計で手が止まり、匿名ロールから呼べる関数をそのまま本番へ出す、という順番で詰まります。この記事では、エンドポイントが生成される仕組み、select と絞り込みと入れ子取得の書き方、処理を Postgres 関数へ寄せて rpc で呼ぶ設計、公開範囲の制御までを、2026年8月時点の PostgREST 16.2系と supabase-js 2.112系に沿って並べます。
まとめ:自動生成APIとrpcの境界を先に決める3つの判断
先に結論を出します。自動生成 API は「1テーブルへの単純な読み書きを、サーバーコードを書かずに済ませる」道具です。1リクエストが1トランザクションで完結するため、複数テーブルをまたいで整合性を保つ更新は、そのままでは書けません。ここが rpc、つまり Postgres 関数呼び出しへ寄せる境界になります。
着手前に決めるのは3点です。第一に公開スキーマで、public をそのまま外へ出すか、api のような専用スキーマを1枚かぶせるかを決めます。後から移すとクライアント側の全参照を書き換える羽目になるため、巻き戻しの高い判断です。第二に権限の初期値で、テーブルにも関数にも既定の権限が付いた状態から始まります。第三に整合性の単位で、守りたい不変条件が1回の呼び出しに収まらないなら、その処理は関数へ入れてください。
逆に、自動生成 API のままで押し切ったほうが速い場面もあります。管理画面の一覧、単一テーブルのマスタ編集、所有者で絞れる読み取り。この範囲なら関数を作る手間のほうが高くつきます。判断の分かれ目は「不変条件が複数行にまたがるか」の一点です。
テーブル定義からRESTエンドポイントが生える仕組みと認証ヘッダの型
PostgREST は起動時にデータベースのカタログを読み、テーブル・ビュー・関数の一覧をメモリ上に持ちます。これがスキーマキャッシュです。URL のパスはテーブル名やビュー名そのままで、クエリ文字列が SQL の where や order by に対応します。コード生成ではなく、その場で SQL へ翻訳する仕組みだと捉えてください。
スキーマキャッシュを読んでURLを組む仕組みと反映のタイミング
エンドポイントの実体は /rest/v1/ 配下です。テーブル posts があれば、そのまま /rest/v1/posts が読み書きの口になります。ビューも同じ扱いで、更新可能ビューなら書き込みも通る形です。この性質を使えば、テーブルを直接出さずビューだけを公開して列を隠す設計が取れます。
GET https://<project-ref>.supabase.co/rest/v1/posts
?select=id,title,created_at
&status=eq.published
&order=created_at.desc
&limit=20
apikey: <publishable key>
Authorization: Bearer <user access token>
Accept: application/json
注意したいのは、キャッシュが更新されるタイミングです。作った直後の関数を呼んで「存在しない」と返る場合、データベース側には在ってもキャッシュが古い状態です。管理画面から定義を変えたときは通常すぐ追いつきますが、マイグレーション直後にテストが落ちる症状はここを疑ってください。流し方はSupabase CLIとローカル開発環境の記事で扱っています。
apikeyとAuthorizationヘッダが決めるロールの切り替わり
リクエストがどのデータベースロールとして実行されるかは、ヘッダで決まります。ログイン前は匿名ロール、ログイン後はユーザーの JWT に対応するロールへ切り替わり、その状態で行レベルセキュリティのポリシーが評価される流れです。つまり「誰が読めるか」の判定はアプリ側のコードではなく、データベース側に置かれます。
ここで事故になりやすいのが、サーバー側専用の鍵をクライアントへ持ち出す実装です。この鍵はポリシーを素通りするため、ブラウザに載せた時点で全行が読める状態になります。鍵の置き場所と漏れたときの回し方はSupabaseのAPIキー管理の記事、ポリシーの書き方はSupabase RLSの設定手順の記事を参照してください。
1リクエスト1トランザクションという前提が設計に効いてくる場面
PostgREST は1回の HTTP リクエストを1つのトランザクションとして処理します。逆に言えば、2回のリクエストにまたがるトランザクションは張れません。在庫を1行減らして注文を1行作る、という2テーブルの更新をクライアントから2回呼びで実装すると、間で失敗したときに片方だけ残ります。
この制約は回避策で埋めるものではなく、設計の分岐点として使います。守りたい不変条件が1リクエストに収まらないなら関数へ寄せる。判断はこれだけで足ります。
selectとfilterの書き方:列の絞り込みと並び順・件数の取り方
クエリの表現力は意外に広く、単純な一覧画面なら SQL を書かずに済みます。ただし表現できる範囲には線があり、その線を知らないと無理な書き方で粘ることになります。
列の絞り込みと単一行取得:singleとmaybeSingleの使い分け
取得列は文字列で列挙します。* を投げる実装が残っていると列を追加した瞬間に転送量が増えるため、画面が使う列だけを書く癖をつけてください。
const { data, error } = await supabase
.from('posts')
.select('id, title, created_at')
.eq('status', 'published')
.gte('created_at', '2026-08-01')
.order('created_at', { ascending: false })
.range(0, 19)
1行だけ欲しいときは single か maybeSingle を使います。前者は該当が0行でもエラー扱いになり、後者は0行を null として返す挙動です。「見つからなければ404を出す」なら前者、「無ければ初期値で描く」なら後者を選びます。ここを取り違えると、正常系のはずの分岐がエラーログを埋めます。
フィルタ演算子の対応表と、論理演算や全文検索を渡すときの書き方
演算子は SQL と対応し、URL 上では「列=演算子.値」の形です。よく使うものを並べます。
| メソッド | URL表記 | 対応するSQL |
|---|---|---|
| eq | col=eq.v | col = v |
| in | col=in.(a,b) | col in (a, b) |
| gte・lte | col=gte.v | col >= v |
| like・ilike | col=ilike.*v* | col ilike ‘%v%’ |
| is | col=is.null | col is null |
| contains | col=cs.{a} | 配列・jsonbの包含 |
| 全文検索 | col=fts.v | 全文検索の演算子 |
or 条件は or に文字列で渡します。読みづらくなりやすいので、3つ以上の論理条件が絡んだら関数化を検討する目安にしてください。集約は割り切りが必要です。avg や sum は PostgREST 側の機能として存在しますが、本体の既定では無効な設定項目に依存します。合計や件数を画面に出す要件なら、関数の中で集計して返す形が環境差に振られません。
件数の取り方とページング:countとrangeの組み合わせ
総件数は count オプションで取ります。本文が不要なら head で件数だけ受け取れます。
const { count } = await supabase
.from('posts')
.select('*', { count: 'exact', head: true })
.eq('status', 'published')
ここで exact は毎回数え上げるため、行数が増えるほど遅くなります。数十万行規模の一覧では、推定値を返すモードへ切り替えるか、総件数を出さない設計へ寄せてください。返却行数には上限の設定があり、既定のままでは一覧が途中で切れるため、range でのページングを前提に組んでください。
embedでリレーションを取る書き方とヒント指定・内部結合の切り替え
入れ子取得は、この API の生産性がもっとも上がる部分です。同時に、失敗の原因がわかりにくい部分でもあります。仕組みは外部キー制約をたどって関係を推論するだけです。
外部キーがあれば入れ子で取れる:埋め込みの基本形と別名の付け方
select の中に関連テーブル名を書くと、その関係が入れ子の JSON として返ります。別名も付けられるため、クライアント側の型に合わせて整えられます。
const { data } = await supabase
.from('posts')
.select('id, title, author:profiles(display_name), tags(name)')
.eq('status', 'published')
1対多なら配列、多対1ならオブジェクトが返ります。生成される SQL は結合1本なので、クライアントから複数回呼んで組み立てるより往復が減る構造です。取得した JSON をそのまま画面へ渡す前提で列を設計すると、変換コードが薄くなります。クライアントをどこに置くかはSupabaseとNext.jsの連携記事を参照してください。
PGRST200とPGRST203を避けるヒント指定と関係の曖昧さ
入れ子で失敗する原因の大半は、関係が推論できないか、候補が複数あるかのどちらかです。外部キー制約を張っていないテーブル同士を入れ子で書けば、関係が見つからない旨のエラーが返ります。逆に author_id と reviewer_id の両方が profiles を指しているような場合、どちらをたどるか決められません。
.select('id, author:profiles!posts_author_id_fkey(display_name)')
制約名をヒントとして渡せば曖昧さは消えます。運用としては、制約名を人が読める形で付けておくのが効く方法です。自動生成の名前をヒントに書くと、マイグレーションのやり直しで名前が変わった瞬間にクエリが壊れます。
入れ子側の条件で絞るときに内部結合へ切り替える必要がある理由
入れ子は既定で外部結合に近い挙動で、関連が無い行も親側は返ります。ところが入れ子側の列を条件に使う場合、このままでは絞り込みが効きません。
.select('id, title, author:profiles!inner(display_name)')
.eq('author.display_name', 'nanakura')
!inner を付けると内部結合に切り替わり、条件に合わない親行が落ちます。「入れ子側で絞るなら内部結合」と対で覚えておくと、絞り込んだはずなのに件数が変わらない現象を踏まなくなります。
複雑な処理をPostgres関数へ寄せてrpcで呼ぶ設計の組み立て方
ここからが設計の本題です。関数を作ると /rest/v1/rpc/ 配下にエンドポイントが生え、テーブルと同じ名前空間での衝突を避ける構造になっています。この形の価値は「複雑な SQL を隠せる」ことよりも、トランザクション境界をサーバー側に置ける点にあります。
関数の言語とsecurity設定:invokerとdefinerの選び方
関数は呼び出したロールの権限で動く既定の形と、定義したロールの権限で動く形を選べます。行レベルセキュリティに判定を任せたいなら前者です。ポリシーを越えて処理させたい場合だけ後者を選び、そのときは関数の中で認可判定を書く責任が発生します。後者を選ぶなら検索パスを空に固定し、想定外のスキーマを見に行かないようにしてください。
create or replace function public.place_order(p_item_id bigint, p_qty int)
returns bigint
language plpgsql
security invoker
set search_path = ''
as $$
declare
v_order_id bigint;
begin
update public.items
set stock = stock - p_qty
where id = p_item_id
and stock >= p_qty;
if not found then
raise exception 'out of stock'
using errcode = 'P0001';
end if;
insert into public.orders (item_id, qty, user_id)
values (p_item_id, p_qty, auth.uid())
returning id into v_order_id;
return v_order_id;
end;
$$;
この関数では、在庫を減らす更新と注文を作る挿入が同じトランザクションに入ります。在庫が足りなければ例外を投げ、更新も巻き戻る形です。クライアントから2回呼びにした場合との差は、失敗時に中途半端な状態が残るかどうかです。
GETで呼べる関数の条件とsetof戻り値にフィルタを重ねる書き方
データベースを変更しない関数、つまり immutable か stable を指定した関数は GET でも呼べます。GET なら経路上のキャッシュに載せられるため、検索や集計の読み取りで効きます。
const { data } = await supabase
.rpc('search_posts', { q: 'supabase' }, { get: true })
.eq('status', 'published')
.order('created_at', { ascending: false })
.limit(10)
戻り値をテーブル型にしておくと、テーブルと同じフィルタや並び替えを後ろに重ねられます。関数側で全部の条件を引数にする必要がなくなるため、引数が10個並ぶ関数を作りかけたら、この形へ寄せられないか考えてください。
引数の渡し方と例外の返し方:エラーコードを決めて呼び出し側に拾わせる
引数はオブジェクトで渡し、キーが関数のパラメータ名に対応します。配列も渡せるため、1回の呼び出しで複数件をまとめて処理する関数も作れる形です。既定値を持つパラメータは省略できます。
const { data, error } = await supabase
.rpc('place_order', { p_item_id: 12, p_qty: 2 })
if (error) {
console.error(error.code, error.message)
}
業務エラーの返し方は先に決めておきます。例外に独自のエラーコードを付けておけば、クライアント側は文言ではなくコードで分岐できる形です。文言で分岐する実装は、メッセージを直した日に壊れます。外部 API の呼び出しや長い処理が混ざるなら、関数の中で粘らずにSupabase Edge Functionsの記事の実行環境へ出すか、pgmqの記事のようにキューへ積んで切り離してください。この境界の切り方は既存システムとつなぐ設計そのものなので、迷う段階であればAPI開発・システム連携の相談として持ち込んでもらえれば、要件から一緒に引きます。
スキーマの公開範囲を絞る手順とanonロールの初期権限の落とし穴
ここは事故が起きたときの被害がもっとも大きい部分です。既定の構成は開発を始めやすい側へ振られているため、本番前に自分で絞り込む工程が必要になります。
publicのままか専用スキーマへ寄せるかを決める判断の順序
初期状態では public スキーマが外へ出ています。公開範囲は管理画面の API 設定にある Exposed schemas で決まり、ここに書いていないスキーマは外から触れません。内部用のテーブルを internal のような別スキーマへ置き、外へ出すものだけを api スキーマにビューと関数で並べれば、公開面が一覧で確認できます。
create schema if not exists api;
grant usage on schema api
to anon, authenticated, service_role;
grant all on all tables in schema api
to anon, authenticated, service_role;
grant all on all routines in schema api
to anon, authenticated, service_role;
grant all on all sequences in schema api
to anon, authenticated, service_role;
この判断は先に済ませてください。後から移すと、クライアント側の全参照とデフォルト権限をまとめて直すことになります。逆に、社内向けの小さな管理画面まで専用スキーマで固める必要はありません。未認証ユーザーが触る面があるかどうかが分岐です。
関数のEXECUTEが既定で誰にでも付いているという落とし穴
Postgres では、作成した関数の実行権限が既定で広く付きます。公開スキーマに関数を1つ置いた時点で、匿名ロールから呼べる状態だという意味です。行レベルセキュリティはテーブルへのアクセスを守りますが、関数の実行そのものは止めません。定義したロールの権限で動く関数を無防備に置けば、ポリシーを越えた処理を誰でも起動できます。
revoke execute
on function public.place_order(bigint, int)
from anon, public;
grant execute
on function public.place_order(bigint, int)
to authenticated;
テーブル側も同様で、public に作ったテーブルには匿名ロールや認証済みロール向けの権限が既定で付きます。行レベルセキュリティを有効にしていれば行そのものは守られますが、有効化を忘れた1テーブルがそのまま全公開になる構造です。検知手順は RLS の記事側に置いてあります。
Data APIを止める選択肢と、止めたときに残る接続経路の整理
クライアントライブラリや REST を使わず、サーバーから接続文字列で直接つなぐ構成なら、Data API 自体を止められます。管理画面の設定に Enable Data API のトグルがあり、落とせば HTTP 経由の口が閉じます。Supabase を Postgres として使うだけの構成では素直な選択です。
止めたあとも認証やストレージの経路は残るため、「API を止めたから外向きの面はゼロ」とは考えないでください。機能ごとに開いている口を一度書き出すほうが確実です。
詰まりやすいエラーとPGRSTコードから原因を切り分ける手順
返ってくるエラーには PGRST で始まるコードが付きます。訳文を探すより、コードで原因の層を切り分けるほうが早く済みます。
PGRSTコード早見表と、スキーマキャッシュ由来の失敗の見分け方
| コード | 意味 | 最初に見る場所 |
|---|---|---|
| PGRST100 | クエリ文字列の解析失敗 | 演算子と値の書式 |
| PGRST116 | 単数応答で1件以外が返った | singleの使用箇所 |
| PGRST200 | 埋め込み先の関係が無い | 外部キー制約の有無 |
| PGRST202 | 関数が無いか署名が古い | 引数名と型・キャッシュ |
| PGRST203 | 関数の候補が絞れない | 同名関数の多重定義 |
| PGRST301 | JWTが復号できない | トークンの発行と期限 |
関数を作ったのに呼べない場合、まず疑うのは引数名です。呼び出し側のキーがパラメータ名と一致していないと、署名が合わず見つからない扱いになります。同名で引数の型だけ違う関数を並べたときも候補が絞れず失敗するため、オーバーロードする設計は避けてください。
行が返らないときにRLSと権限のどちらを疑うかの切り分け手順
「エラーは出ないが空配列が返る」ときは、行レベルセキュリティのポリシーで全行が落ちている可能性が高いです。一方で「権限がない」旨のエラーが返るなら、ロールへの権限付与そのものが足りていません。ポリシーは権限がある前提で行を絞る仕組みなので、この2つは別の層です。
切り分けは、管理画面の SQL エディタで対象ロールになりきって同じクエリを流すのが確実です。そこで行が返るならクライアント側のトークン、返らないならポリシーの条件式が原因だと絞れます。
自動生成APIで押し切る場面とrpcへ寄せる場面の線引きと判断条件
両方を使えるからこそ、どちらに寄せるかの基準がないとコードが混ざります。
自動生成APIのままで押し切ってよい4つの条件と確認の進め方
次の4つが揃っているなら、関数を作らずに済ませてください。第一に、1回の呼び出しで触るテーブルが1つであること。第二に、守りたい不変条件がその1行の中で完結すること。第三に、認可の判定が行レベルセキュリティのポリシーで書けること。第四に、返す形が入れ子取得で表現できること。
管理画面のマスタ編集、記事一覧、ユーザー自身のプロフィール更新はここに収まります。この範囲で関数を作り始めると、SQL の中に画面の都合が入り込み、画面を変えるたびにマイグレーションを流す運用です。画面ごとに「触るテーブル数」と「またぐ不変条件」を書き出し、数が1のままなら押し切って構いません。
rpcへ寄せる判断になる3つの場面と、寄せすぎたときの副作用
関数へ寄せるのは、複数テーブルの更新を一括で成功か失敗にしたい場面、集計や検索のロジックをクライアントへ出したくない場面、そして同じ処理を複数のクライアントから呼ぶ場面です。3つ目は見落とされやすく、Web とバッチと管理ツールで同じ更新手順を3回書いている状態は、関数1本にまとめる価値があります。
ただし寄せすぎると別の負債が出ます。業務ロジックが SQL の中へ移り、テストの書きにくさとレビューのしにくさが上がる状態です。デプロイの単位もアプリではなくマイグレーションになるため、切り戻しの手数も増えます。目安は、整合性のために置くものは関数へ、条件分岐が多い業務の手続きはアプリ側へ、という分け方です。
この構成を見送って自前のAPIサーバーを立てるべき場面の条件
見送る判断もあります。認証方式の異なる複数の外部システムをリクエストごとに呼ぶ必要がある場合、この構成の中に収めるのは無理筋です。監査ログや承認フローのように状態遷移を厳密に管理したい業務も、データベース関数だけで組むと見通しが悪くなります。既存システムの権限モデルへ合わせる要件があるなら、間にサーバーを1枚置くほうが素直です。
採用条件を言い切ります。単一テーブル中心の読み書きが8割を占め、認可を所有者単位で表現でき、外部連携が数本に収まるなら、自動生成 API と関数の組み合わせで押し切って構いません。この3つのうち2つ以上が崩れるなら、Supabase は Postgres と認証の基盤として使い、業務の口は自前の API サーバーへ寄せてください。製品全体の機能範囲と料金の考え方はSupabaseとFirebaseの比較記事で整理しています。
よくある質問
rpcとEdge Functionsはどう使い分けますか?
データベースの中で完結する処理は関数、外部との通信や重い処理は実行環境側という分け方が基本です。関数はトランザクションの中で動くため、外部 API を混ぜると相手の応答待ちの間トランザクションを開けたままになります。外部連携が絡む処理は実行環境へ出し、そこから関数を呼んでください。
集計をクライアントから直接書くことはできますか?
PostgREST には集約の機能がありますが、本体の既定では無効な設定項目に依存します。環境差で動かなくなるリスクを避けるなら、集計は関数の中に書いて戻り値をテーブル型にしておくほうが確実です。読み取り専用の指定を付けておけば GET で呼べるため、キャッシュにも載せられます。
作ったばかりの関数が見つからないと言われるのはなぜですか?
まず引数名を確認してください。呼び出し側のキーがパラメータ名と一致していないと、署名が合わず存在しない扱いになります。名前と引数が合っているのに失敗する場合は、スキーマキャッシュが古い状態です。定義を再適用して呼び直すか、少し待って再試行すると解消します。
入れ子で絞り込んだのに件数が変わらないのはなぜですか?
入れ子は既定で外部結合に近い挙動をするため、入れ子側の条件では親行が落ちません。!inner を付けて内部結合へ切り替えると、条件に合わない親行が除かれます。入れ子側の列を条件に使うときは内部結合、と対で覚えておくと踏みません。
publicスキーマをそのまま公開したままで問題ありますか?
小規模で内部向けなら実務上は回ります。ただし新しいテーブルを作った瞬間に公開面が広がるため、行レベルセキュリティの有効化を1つ忘れると全公開になります。外部の未認証ユーザーが触る面があるなら、専用スキーマへ寄せて公開対象を明示してください。
関連記事
- SupabaseとFirebaseの違いを徹底比較|料金・認証・どっちを選ぶか:機能範囲と料金の全体像です。
- Supabase APIキーの管理:publishable/secret移行とレガシー鍵の扱い:出してよい鍵の線引きです。
- Supabase RLSの設定手順とAuth連携ポリシー設計|有効化漏れの検知まで:認可判定の書き方です。
- Supabase Next.js連携の実装|proxyとgetClaimsで組むApp Router構成:クライアントの置き場所です。
- Supabase CLIとローカル開発環境:db diffで進めるマイグレーションとCI:変更を反映する運用です。
- Supabase Edge Functionsとは:Deno実行環境とデプロイ・シークレット・課金単位:関数の外へ出す受け口です。
- pgmqとは?PostgreSQLをメッセージキューにする拡張の使い方:非同期構成の参考です。