"Supabase lets you build fast" — this is true. But building fast and withstanding production are different things. Distributing the anon key to clients as-is, publishing tables without writing RLS, trusting getSession() on the server — these "it works for now" implementations come back as an incident the day after launch.
This article is a practical guide for using Supabase to the fullest in production safely, fast, and maintainably. The first half covers the design decision of "when to choose it," the second half the real code of auth, RLS, Realtime, Edge Functions, Storage, and Vector.
The rule of this article: the primary source is the official docs. All APIs, SQL, and recommendations in the body are based on content confirmed at supabase.com/docs as of 2026-06-24. Supabase is a fast-updating product. API names and recommendations can change, so I've appended the primary-source URL at the end of each section. Always confirm the latest before implementing. The article's role is to provide a map for digesting the official docs faster and more correctly.
0. What Is Supabase (the Official Definition)
The official docs position Supabase as "an open source Firebase alternative" — i.e., a backend development toolkit with Postgres at its core. What's important is that this is not "an abstraction of Postgres" but raw Postgres itself. In the official words, "Every Supabase project is a full Postgres database." Underlying it is standard Postgres you can pg_dump and take out anytime, not an escape-proof proprietary DB.
Supabase is a configuration that bundles open-source components around that Postgres (architecture).
| Component | Role | Substance |
|---|---|---|
| Postgres | The database itself | PostgreSQL |
| Studio | Management dashboard | OSS |
| Auth (GoTrue) | User management, JWT issuance | GoTrue |
| Data API | Auto-RESTify tables | PostgREST |
| Realtime | WebSocket (change subscription, Presence, Broadcast) | Realtime Server |
| Storage | S3-compatible object storage | Storage API |
| Edge Functions | Serverless functions (TypeScript) | Supabase Edge Runtime (Deno-compatible) |
| Supavisor | Connection pooler | Supavisor |
| Kong | API gateway | Kong (NGINX) |
And for AI uses, with the pgvector extension, it puts forward the design philosophy "The best vector database is the database you already have" (guides/ai).
Primary source: What is Supabase / Architecture
1. When to Choose Supabase (Right-Tool-for-the-Job Judgment)
The most common failure in technology selection is "because it's trendy." I make my practical criteria explicit, along with the trade-offs.
Cases It Suits
- A relational data model is central. You want to lean transactions, foreign keys, JOINs, and constraints into the DB.
- You want auth, storage, and realtime in one stop. The initial cognitive load and cost are overwhelmingly lower than assembling Auth0 + S3 + a WebSocket server separately.
- You want to launch fast with few people. An MVP-to-medium SaaS for one person to a small team.
- You want to hit the DB directly from the client (web/mobile). The domain where the combination of PostgREST and RLS shines.
- You want to leave open the possibility of future migration. Since it's raw Postgres, lock-in is shallow.
Cases to Consider Carefully
- Apps where ultra-high-frequency writes × delivery to all clients is the star. Mistake the Realtime design and you get stuck on cost and scale (see "Broadcast vs. Postgres Changes" later).
- A mass of complex backend domain logic. Edge Functions are for lightweight APIs/webhooks. The body of heavy business logic should often go in a separate app tier (Next.js Route Handlers / a dedicated API server).
- An organization that already has huge Postgres-operation know-how. The freedom of self-operation can win over managed constraints.
The judgment trick: Supabase is a litmus test for "is a Postgres-centric design right?" If you want to lean the truth of data into the DB, it works powerfully. Conversely, with a design that sees the DB as a mere persistence layer, you discard much of Supabase's appeal (RLS, PostgREST, Realtime authorization).
2. Auth × Next.js 16 (App Router) — The Correct Manner of @supabase/ssr
This is the most accident-prone area. Let's nail down the 2026 right answer, faithful to the official docs.
2.1 Premise: The Package to Use and the Environment Variables
- The auth client is
@supabase/ssr(used together with@supabase/supabase-js). - The old
@supabase/auth-helpers-nextjsis deprecated. Don't use it in new implementations (migration guide). - For environment variables, the current publishable key naming is recommended (the old
ANON_KEYalso works for backward compatibility).
# .env.local
NEXT_PUBLIC_SUPABASE_URL=<your-project-url>
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<your-publishable-key>
npm install @supabase/supabase-js @supabase/ssr
2.2 Iron Rule: Use Only getAll / setAll for Cookies
Take the official docs' most important warning at face value.
Use only
getAllandsetAll. Never useget/set/remove.
get / set / remove are deprecated, hard to implement correctly, and can't handle edge cases. Break this and you produce hard-to-reproduce, troublesome bugs: "random logouts," "premature session termination," and "state inconsistency" (creating-a-client).
2.3 The Browser Client
// lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";
export const createClient = () =>
createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
);
2.4 The Server Client (Next.js 16: cookies() Is await)
In Next.js 15/16, cookies() is asynchronous. The correct form is to make createClient async and await cookies().
// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
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 Component から setAll が呼ばれたケース。
// ミドルウェアでセッションを更新しているなら無視してよい。
}
},
},
},
);
}
The intent of the try/catch is clear: a Server Component can't rewrite the response's cookies, so a write failure here is within expectations, and the actual cookie update is handled by the next middleware.
2.5 Middleware: Refresh the Session on Every Request
Since a Server Component can't update (write) the token, the middleware bears the role of refreshing the token per request. Omit this and you're silently logged out the moment the access token expires.
// lib/supabase/middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function updateSession(request: NextRequest) {
let supabaseResponse = 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),
);
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("/app")) {
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
return supabaseResponse;
}
// middleware.ts
import { type NextRequest } from "next/server";
import { updateSession } from "@/lib/supabase/middleware";
export async function middleware(request: NextRequest) {
return await updateSession(request);
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.svg$).*)"],
};
2.6 Choosing Among the 3 APIs for Judging "Who" on the Server
This is the heart of auth. The behavior and trustworthiness of the three options are completely different.
| API | What it does | OK to trust on the server? |
|---|---|---|
getSession() | Just reads the stored session | ❌ Must not trust. Doesn't guarantee token re-verification |
getUser() | Network request to the Auth server to verify identity | ✅ Authentic. May be the basis for authorization |
getClaims() | Verifies the JWT with JWKS and extracts claims | ✅ Recommended. With asymmetric keys, fast via local verification |
The official docs are clear.
Don't trust
getSession()inside server code. Since it doesn't guarantee token re-verification, it must not be the basis for authorization. Use it only when reading raw tokens (access/refresh/expiry).
Meanwhile, getClaims() is the new recommendation: it verifies the JWT at the JWKS endpoint before returning claims. If the project uses asymmetric signing keys (ES256/RS256), it completes with local verification (often with caching too, so much faster than getUser()); with a symmetric key (HS256), it queries the Auth server.
// Server Component / Route Handler での認可
import { createClient } from "@/lib/supabase/server";
export default async function DashboardPage() {
const supabase = await createClient();
// 推奨:getClaims()(非対称鍵ならローカル検証で高速)
const { data, error } = await supabase.auth.getClaims();
if (error || !data) redirect("/login");
const userId = data.claims.sub;
// …userId を根拠に認可・データ取得
}
The conclusion on usage
- Page/data protection (authorization decision):
getClaims()as the first choice. Even without asymmetric keys,getUser()can substitute.- Token refresh in middleware:
getUser()(forces a refresh).- Only when you need raw tokens:
getSession(). Don't use it for authorization.
2.7 JWT Signing Keys: From Symmetric to Asymmetric (2025~GA)
What enables getClaims()'s fast local verification is JWT Signing Keys. The conventional "sign all JWTs with one shared secret" method has become deprecated (remaining for backward compatibility).
- ES256 (NIST P-256 elliptic curve) = recommended. Faster than RSA, with shorter signatures = smaller cookies.
- RS256 (RSA 2048): widely supported but slower. The official docs recommend P-256.
- EdDSA (Ed25519): "Coming soon."
- HS256 (shared secret): not recommended for production.
Enable asymmetric keys and JWT verification can be done without going through the Auth server, fetching the public key from the JWKS endpoint https://<project>.supabase.co/auth/v1/.well-known/jwks.json. Key rotation flows "standby → current → previously-used → revoke," and can be turned with zero downtime, without force-logging-out users.
The practical meaning: asymmetric keys +
getClaims()dramatically lower the server-side authorization cost of an app that heavily uses RLS. For a new project, the standard is to enable ES256 from the start.
Primary source: Server-side Auth (Next.js) / Creating a client / getClaims / JWT Signing Keys
3. Row Level Security (RLS) — Lean Authorization into the DB
Since you expose tables to clients with PostgREST, enforcing authorization at the DB's row level is the grand principle. In the official words, "RLS is the mechanism for making it safe even to query the DB directly from the client."
The "why" of RLS and the design decision in the extreme case of offline simultaneous editing are dug into, with a real product as the subject, in a separate article, "Design that doesn't trust the client: leaning consistency and authorization into PostgreSQL in an offline simultaneous-editing app." This section systematizes the correct way to write it and its performance, as an official-compliant reference.
3.1 Enabling and the Shape of a Policy
-- まずテーブルでRLSを有効化(これを忘れると全公開)
alter table public.todos enable row level security;
The basic form of a policy is as follows.
create policy "<ポリシー名>"
on <テーブル>
for <select | insert | update | delete | all>
to <ロール> -- 例: authenticated, anon
using (<可視性・対象の条件>) -- SELECT/UPDATE/DELETE:既存行のフィルタ
with check (<書き込み値の検証>); -- INSERT/UPDATE:新しい行の値の検証
Real examples per operation (faithful to the official current style):
-- SELECT:自分のtodoだけ見える
create policy "Individuals can view their own todos."
on public.todos for select
using ( (select auth.uid()) = user_id );
-- INSERT:自分名義でだけ作成できる
create policy "Users can create a profile."
on public.profiles for insert
to authenticated
with check ( (select auth.uid()) = user_id );
-- UPDATE:using と with check の両方が要る
create policy "Users can update their own profile."
on public.profiles for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
-- DELETE
create policy "Users can delete their own profile."
on public.profiles for delete
to authenticated
using ( (select auth.uid()) = user_id );
Two points.
usingis "which existing rows you may touch" (SELECT/UPDATE/DELETE);with checkis "whether the value you write is correct" (INSERT/UPDATE). Their roles differ, so for UPDATE you write both.- That the official example wraps it in
(select auth.uid())as a subquery from the start is no accident. This is the default style for the performance optimization discussed later.
3.2 The Auth Helpers Usable Inside a Policy
auth.uid()… the ID of the in-request user (the JWT'ssub).auth.jwt()… returns the whole JWT asjsonb. You can traverse it likeauth.jwt() -> 'app_metadata'.- Put the data used for authorization in
app_metadata, notuser_metadata.user_metadatais rewritable by the user themselves, so making it the basis for authorization allows privilege escalation.
- Put the data used for authorization in
- Enforce MFA (AAL2) with a restrictive policy:
create policy "Restrict updates to MFA users."
on public.profiles
as restrictive
for update
to authenticated
using ( (select auth.jwt()->>'aal') = 'aal2' );
3.3 RLS Performance Optimization (This Is Where the Difference Shows)
Since RLS is evaluated per row, it gets an order of magnitude slower depending on how you write it. Let me organize the optimizations the official docs list, and the in-house benchmark values the docs show (reference values under test conditions, not guaranteed values).
| Optimization | How | Official-bench improvement (reference value) |
|---|---|---|
| ① Index | Index the columns used in the policy | up to about 99.94% |
| ② Wrap functions in a subquery | auth.uid() → (select auth.uid()) to evaluate once per statement | about 94.97–99.99% |
| ③ Explicit filter | Add .eq('user_id', userId) on the client side too | about 94.74% |
④ Specify the role with TO | Skip irrelevant roles early with to authenticated etc. | about 99.78% |
⑤ security definer function | Push the authorization-table reference into a function to avoid RLS re-evaluation | about 99.78% |
| ⑥ Reduce JOINs | Judge with in/any on the set of allowed IDs rather than a JOIN with the authorization table | — |
-- ①インデックス
create index idx_todos_user_id on public.todos using btree (user_id);
The logic of ② is "wrap it in (select ...) and it's cached in the initial plan (initPlan), so it's evaluated only once per statement, not per row." It's the most cost-effective single move when writing RLS.
How to treat the numbers: the percentages above are values the official docs measured on a specific test table. Take them in the context of "in Supabase's benchmark, up to ~% improvement," and always confirm with
explain analyzeon your own schema. There's no fabricated panacea.
3.4 The Correct Way to Write a security definer Function
When pushing the authorization-table reference into a function (⑤), use security definer. And always fix search_path then. Neglect this and it becomes an entry point for privilege escalation via schema pollution.
create function public.hello_world()
returns text
language plpgsql
security definer set search_path = '' -- 必須。空にしたら関数内は public.table と明示する
as $$
begin
return 'hello world';
end;
$$;
The official guideline is "first use security invoker (the default). If you use security definer, setting search_path is mandatory."
3.5 RBAC: Put Roles in the JWT with the Custom Access Token Hook
For role-based authorization like "admin" or "moderator," the official pattern is to inject the role into a JWT claim with the Custom Access Token Hook and have the policy side just read auth.jwt(). It's faster and more declarative than JOINing a table every time.
-- 1) ロールと権限の型・テーブル
create type public.app_permission as enum ('channels.delete', 'messages.delete');
create type public.app_role as enum ('admin', 'moderator');
create table public.user_roles (
id bigint generated by default as identity primary key,
user_id uuid references auth.users on delete cascade not null,
role app_role not null,
unique (user_id, role)
);
-- 2) トークン発行前に user_role をクレームへ注入するフック
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable
as $$
declare
claims jsonb;
user_role public.app_role;
begin
select role into user_role from public.user_roles
where user_id = (event->>'user_id')::uuid;
claims := event->'claims';
if user_role is not null then
claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
else
claims := jsonb_set(claims, '{user_role}', 'null');
end if;
event := jsonb_set(event, '{claims}', claims);
return event;
end;
$$;
-- 3) フックは supabase_auth_admin として実行される。権限を厳格に絞る
grant usage on schema public to supabase_auth_admin;
grant execute on function public.custom_access_token_hook to supabase_auth_admin;
revoke execute on function public.custom_access_token_hook from authenticated, anon, public;
grant all on table public.user_roles to supabase_auth_admin;
revoke all on table public.user_roles from authenticated, anon, public;
create policy "Allow auth admin to read user roles" on public.user_roles
as permissive for select to supabase_auth_admin using (true);
And from the policy, judge the permission via an authorize() helper.
create or replace function public.authorize(requested_permission app_permission)
returns boolean
language plpgsql
stable
security definer set search_path = ''
as $$
declare
bind_permissions int;
user_role public.app_role;
begin
select (auth.jwt() ->> 'user_role')::public.app_role into user_role;
select count(*) into bind_permissions
from public.role_permissions
where permission = requested_permission and role = user_role;
return bind_permissions > 0;
end;
$$;
create policy "Allow authorized delete" on public.channels
for delete to authenticated
using ( (select authorize('channels.delete')) );
The hook must be enabled in the dashboard (Authentication → Hooks) or in config.toml.
Primary source: Row Level Security / Database Functions / RBAC (Custom Claims)
4. Realtime — Broadcast, Presence, Postgres Changes
Realtime has three features (realtime).
- Broadcast … low-latency messages between clients. Chat, cursor tracking, game events, notifications.
- Presence … state sync of "who's online."
- Postgres Changes … subscription to DB changes (INSERT/UPDATE/DELETE).
4.1 An Important Shift: If Scale Appears, Broadcast over Postgres Changes
Intuitively, "subscribe to DB changes (Postgres Changes)" looks like the royal road. But the current official recommendation is to use Broadcast (especially "Broadcast from Database") for scale and security.
Official: "We recommend Broadcast for most use cases." "Postgres Changes has constraints at larger scale."
The reason is structural. Postgres Changes flows all WAL changes through a single replication slot and evaluates RLS per client, so it bottlenecks as connections grow. Broadcast from Database fans out directly to a topic from a DB trigger with realtime.broadcast_changes(), scaling far better.
4.2 The Basics of Broadcast (Client)
import { createClient } from "@supabase/supabase-js";
const supabase = createClient("<url>", "<publishable-key>");
const channel = supabase.channel("room-1");
// 受信
channel
.on("broadcast", { event: "shout" }, (payload) => console.log(payload))
.subscribe((status) => {
if (status !== "SUBSCRIBED") return;
// 送信(subscribe後はWebSocket経由)
channel.send({ type: "broadcast", event: "shout", payload: { message: "Hi" } });
});
With broadcast: { self: true } you also receive your own sends, and with broadcast: { ack: true } you get a server ACK.
4.3 Broadcast from Database (the Recommended Configuration)
The current proper way to deliver DB changes scalably. Broadcast with a trigger, and the client receives on a private channel.
create or replace function public.your_table_changes()
returns trigger
security definer
language plpgsql
as $$
begin
perform realtime.broadcast_changes(
'topic:' || coalesce(NEW.id, OLD.id)::text, -- トピック
TG_OP, -- event
TG_OP, -- operation
TG_TABLE_NAME,
TG_TABLE_SCHEMA,
NEW,
OLD
);
return null;
end;
$$;
create trigger handle_your_table_changes
after insert or update or delete on public.your_table
for each row execute function public.your_table_changes();
const gameId = "abc";
await supabase.realtime.setAuth(); // Realtimeの認可トークンを更新(プライベートチャネルに必須)
const channel = supabase
.channel(`topic:${gameId}`, { config: { private: true } })
.on("broadcast", { event: "INSERT" }, (p) => console.log(p))
.on("broadcast", { event: "UPDATE" }, (p) => console.log(p))
.on("broadcast", { event: "DELETE" }, (p) => console.log(p))
.subscribe();
4.4 Realtime Authorization (RLS on realtime.messages)
Access to a private channel is controlled by an RLS policy on the realtime.messages table. It's evaluated at connection time, looking at the JWT, the topic, and the extension type (broadcast/presence).
-- 例:そのルームのメンバーだけ受信を許可
create policy "Members can receive room broadcasts"
on "realtime"."messages"
for select
to authenticated
using (
exists (
select 1 from public.rooms_users
where user_id = (select auth.uid())
and room_topic = (select realtime.topic())
and realtime.messages.extension in ('broadcast', 'presence')
)
);
The access policy is evaluated at connection time and cached during the connection. Complex RLS directly affects connection latency, so keep it simple.
4.5 Presence (Online State)
const room = supabase.channel("room-1");
room
.on("presence", { event: "sync" }, () => console.log(room.presenceState()))
.on("presence", { event: "join" }, ({ key, newPresences }) => console.log("join", key))
.on("presence", { event: "leave" }, ({ key, leftPresences }) => console.log("leave", key))
.subscribe(async (status) => {
if (status !== "SUBSCRIBED") return;
await room.track({ user: "user-1", online_at: new Date().toISOString() });
});
Presence is a feature with high state-sync cost. Mindful that "the load is large with many concurrent connections," use it only on screens that truly need it.
Primary source: Realtime / Broadcast / Subscribing to Database Changes / Realtime Authorization
5. Edge Functions — withSupabase (the New Standard of 2026)
Edge Functions are a TypeScript-first serverless execution environment (Supabase Edge Runtime, Deno-compatible). The way to write them changed in 2025–2026.
5.1 The Current Recommendation: Not Deno.serve but withSupabase
The current quickstart standardizes on wrapping the fetch handler with withSupabase from npm:@supabase/server. Since the return is the standard (Request) => Promise<Response>, the advantage is that it's easy to port to Vercel Functions / Cloudflare Workers / Bun.
import { withSupabase } from "npm:@supabase/server";
export default {
fetch: withSupabase({ auth: ["publishable", "secret"] }, async (req, ctx) => {
const { name } = await req.json();
return Response.json({ message: `Hello ${name}!` });
}),
};
Deno.serveis still supported, but the official current best practice is the form of exportingfetchwithwithSupabase.
5.2 A Client Scoped to the Authenticated User
Specify auth: 'user' and you can receive an RLS-scoped client and the user's claims directly from ctx. There's no need to read the Authorization header around yourself.
import { withSupabase } from "npm:@supabase/server";
export default {
fetch: withSupabase({ auth: "user" }, async (_req, ctx) => {
const { supabase, supabaseAdmin, userClaims, jwtClaims, authMode } = ctx;
// supabase … 認証ユーザーにRLSスコープされたクライアント
// supabaseAdmin … RLSをバイパス(service role)。取り扱い注意
// userClaims … JWTから得たユーザー識別(id, email, role)
return Response.json({ email: ctx.userClaims?.email });
}),
};
supabaseAdmin (the service role) completely bypasses RLS. Leak it to the client and it's the same as handing over all permissions. Use it only inside the Edge Function, and never mix it into a response or a log.
5.3 CLI and Operation
supabase functions new hello-world # 雛形生成
supabase start # ローカルスタック(Docker)
supabase functions serve hello-world # ローカル実行
supabase functions deploy hello-world # デプロイ(名前省略で全関数)
# supabase functions deploy --use-api # Docker不要のAPI経由デプロイ
Features that matter in real operation:
- Background tasks: continue processing after the response with
EdgeRuntime.waitUntil(promise)(background-tasks). - Region-specified invocation:
supabase.functions.invoke('fn', { region: FunctionRegion.UsEast1 }). Run in a region near the DB to cut latency (regional-invocation). - The runtime supports Deno 2.1.
5.4 Limits (Always Grasp These at Design Time)
| Item | Free | Paid |
|---|---|---|
| Execution time (wall-clock) | 150s | 400s |
| CPU time/request | 2s (actual CPU, excluding async I/O) | same |
| Memory | 256MB | 256MB |
The design implication: the constraints of CPU 2s and memory 256MB show that Edge Functions are for lightweight APIs/webhooks/orchestration, not the body of heavy computation. Split heavy processing, and offload long-running processing to background tasks or a queue (later).
Primary source: Edge Functions / Quickstart / Auth in Functions / Limits
6. Storage — Uploads, Signed URLs, and RLS
// アップロード(upsertやcontentTypeも指定可)
await supabase.storage.from("avatars").upload("public/avatar1.png", file, {
upsert: true,
contentType: "image/png",
});
// 公開バケットのURL
const { data } = supabase.storage.from("avatars").getPublicUrl("public/avatar1.png");
// 非公開ファイルの時間制限つき署名URL(秒指定)
const { data: signed } = await supabase.storage
.from("private-docs")
.createSignedUrl("contract.pdf", 3600); // 1時間
And storage authorization is also RLS. A file's metadata is in the storage.objects table, and you write policies here. The standard is that storage.foldername(name) decomposes the path into an array (1-indexed).
-- 各ユーザーは「自分のIDフォルダ」にだけアップロードできる
create policy "Users upload to their own folder"
on storage.objects for insert to authenticated
with check (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);
-- 自分が所有するオブジェクトだけ閲覧できる
create policy "Owner can read"
on storage.objects for select to authenticated
using ( (select auth.jwt()->>'sub') = owner_id );
Pitfall: if you use
upsert: true, you need not only an INSERT but alsoSELECTandUPDATEpolicies. This is the typical cause of "can upload but fails on overwrite."
Primary source: Storage / Storage Access Control
7. AI / Vector (pgvector) — Your Existing DB Becomes a Vector DB As-Is
Without standing up a separate dedicated vector DB, you can assemble semantic search with Postgres's pgvector extension.
-- 1) 拡張を有効化(extensions スキーマに)
create extension vector with schema extensions;
-- 2) 埋め込み列(次元数はモデルの出力に一致させる)
create table documents (
id serial primary key,
title text not null,
body text not null,
embedding extensions.vector(384)
);
There are three distance operators. Choosing correctly directly affects accuracy.
| Operator | Meaning | Index operator class |
|---|---|---|
<-> | Euclidean distance (L2) | vector_l2_ops |
<#> | Negative inner product | vector_ip_ops |
<=> | Cosine distance | vector_cosine_ops |
<#>is not the "inner product" but the negative inner product (since Postgres indexes only handle ascending order, pgvector flips the sign). For normalized embeddings like OpenAI's,<#>is fast; if normalization status is unknown,<=>(cosine) is a safe default — that's the official guideline.
The RPC for semantic search (match_documents):
create or replace function match_documents (
query_embedding extensions.vector(384),
match_threshold float,
match_count int
)
returns table (id bigint, title text, body text, similarity float)
language sql stable
as $$
select
documents.id, documents.title, documents.body,
1 - (documents.embedding <=> query_embedding) as similarity
from documents
where 1 - (documents.embedding <=> query_embedding) > match_threshold
order by documents.embedding <=> query_embedding asc
limit match_count;
$$;
const { data } = await supabase.rpc("match_documents", {
query_embedding: embedding, // クエリ文を埋め込んだベクトル
match_threshold: 0.78,
match_count: 10,
});
HNSW is the current first recommendation for the index ("recommend HNSW for its performance and robustness to data change"). IVFFlat requires a rebuild when the data distribution changes.
create index on documents using hnsw (embedding vector_cosine_ops);
-- 2,000次元超は halfvec へキャストして索引化
create index on documents using hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops);
Automatic Embeddings
A mechanism to auto-sync embeddings when content is updated is also provided officially. With the combination of pgmq (job queue) + pg_net (async HTTP from the DB) + pg_cron (periodic processing) + Edge Functions (calling the embedding API) + Vault (safely storing the URL), it realizes the flow "when a row changes, push to the queue; Cron generates/updates embeddings in batch; retry on failure" (automatic-embeddings).
Primary source: AI & Vectors / Vector Columns / Semantic Search / Vector Indexes
8. The Development/Operation Footing (Migrations / Branching / Cron / Queues)
8.1 Declarative Schema and Migrations
Write the schema's ideal form in supabase/schemas/*.sql and generate migrations from the diff ("declarative schema") — usable together with conventional migrations.
supabase migration new create_employees_table # 手書きマイグレーション
supabase db diff -f create_cities_table # 宣言的スキーマから差分生成
supabase migration up # ローカル適用
supabase db push # リモートへ反映
An important constraint: the declarative-schema diff tool does not capture DML (insert/update/delete), RLS-policy changes, materialized views, comments, partitions, and so on. Manage these with hand-written migrations. "Leaving RLS-policy changes to the diff and missing them" is a classic accident.
The operational principle is "each developer creates migrations on their own branch and doesn't touch the remote DB directly" and "only one person does db push at a time (they're applied in timestamp order, so they collide)."
8.2 Branching, Read Replicas, Cron, Queues
- Database Branching: an isolated environment branched from production. A Preview Branch (tied to a PR, auto-destroyed) and a Persistent Branch (staging, etc.). A new branch does not contain production data (production-data protection). With GitHub integration, "push to
main→ reflect to production." - Read Replicas: an additional DB asynchronously synced with production. Supports REST GETs only, geo-routing reads to a nearby replica, with writes going to the primary.
- Supabase Cron:
pg_cron-based periodic execution. Can hit SQL / DB functions / HTTP / Edge Functions. - Supabase Queues: a
pgmq-based, Postgres-native durable queue. Delivery within a visibility window, message archiving, authorization via RLS. Effective as a destination for "offloading heavy processing" from Edge Functions.
About pricing: the plans are structured as Free ($0) / Pro ($25+) / Team ($599+) / Enterprise (custom), but each cap, the metered unit price, and the offering conditions for replicas/branches are revised. Always confirm the latest amounts on the official pricing page (this article avoids asserting fixed values).
Primary source: Declarative Schemas / Migrations / Branching / Read Replicas / Cron / Queues
9. Pitfalls Easy to Step Through in Production (Checklist)
Summarized in a form you can use directly in a design review.
- Are you using
getSession()for authorization on the server? → usegetClaims()(recommended) orgetUser(). - Are you using
get/set/removefor cookies? →getAll/setAllonly. - Are you refreshing the session in middleware? → unimplemented means a silent logout.
- Did you enable RLS on all tables? → forgetting
enable row level security= full exposure. - Did you wrap policy functions in
(select auth.uid())? → per-row evaluation to per-statement evaluation. - Did you specify a role like
to authenticatedin the policy? - Did you put authorization data in
app_metadata? (user_metadatais tamperable). - Did you set
search_path = ''onsecurity definerfunctions? - Is the service role key (
supabaseAdmin) leaking to the client/logs? - Are you exposing high-frequency DB-change delivery in full with Postgres Changes? → consider Broadcast from Database.
- Did you add SELECT/UPDATE policies for Storage
upsert? - Are you leaving RLS changes/DML to the declarative-schema diff? → move to hand-written migrations.
- Did you enable ES256 (asymmetric JWT signing key) for the new project?
- Are you loading heavy computation onto an Edge Function? (CPU 2s, 256MB).
Summary: Supabase Is Truly Strong When You "Lean the Design into the DB"
Supabase's value isn't only "you can build fast." Lean authorization into the DB with RLS, keep the trust boundary consistent with Realtime Authorization and PostgREST, and complete everything down to AI in the same DB with pgvector — when you correctly step on this "Postgres-centric design," even a small team reaches enterprise-grade robustness. Conversely, step on the pitfalls listed here and the speed turns into technical debt.
I build and operate production products fast, cheap, and safe with one person × generative AI (Claude Code), crushing these "plain but fatal design decisions behind the flashiness" one by one. If you've adopted (or are considering) Supabase and feel uneasy about the design of auth, RLS, or Realtime, I can accompany you from design review through implementation and operation.
The APIs and recommendations in this article are based on the official docs as of 2026-06-24. Since Supabase updates fast, I strongly recommend confirming the latest at each section's primary-source link before implementing.