# [2026 Edition] Supabase Production Operations Guide: Implementing Next.js × RLS × Realtime × Edge Functions per the Official Docs

> Take Supabase from 'it works for now' to 'it withstands production.' Faithful to the official docs (as of 2026-06-24), a practical guide systematizing — with real code and decision criteria — Next.js 16 auth via @supabase/ssr, the correct way to write RLS and its performance optimization, getClaims and JWT signing keys, Realtime Broadcast, Edge Functions (withSupabase), Storage, and pgvector.

- Published: 2026-06-24
- Author: 友田 陽大
- Tags: Supabase, PostgreSQL, RLS, Next.js, TypeScript, リアルタイム, アーキテクチャ設計
- URL: https://tomodahinata.com/en/blog/supabase-production-guide-nextjs-rls-realtime-edge-functions
- Category: Databases & RLS

## Key points

- Supabase has raw Postgres as its foundation, so lock-in is shallow, and it's strongest when 'Postgres-centric design' — leaning authorization into the DB with RLS — is the premise
- @supabase/ssr auth handles cookies with getAll/setAll only and refreshes the session in middleware. Don't use get/set/remove
- For server-side authorization, getClaims() is the first choice (fast via local verification with asymmetric ES256). Don't use getSession() for authorization
- RLS gets an order of magnitude faster with subquery-wrapping in (select auth.uid()), indexes, and a to role specification; put authorization data in app_metadata
- For Realtime, use Broadcast over Postgres Changes if scale appears; Edge Functions are for lightweight APIs/webhooks due to the 2-second CPU constraint

---

"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](https://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](https://supabase.com/docs/guides/getting-started/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](https://supabase.com/docs/guides/ai)).

> Primary source: [What is Supabase / Architecture](https://supabase.com/docs/guides/getting-started/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-nextjs` is deprecated.** Don't use it in new implementations ([migration guide](https://supabase.com/docs/guides/troubleshooting/how-to-migrate-from-supabase-auth-helpers-to-ssr-package-5NRunM)).
- For environment variables, the current **publishable key** naming is recommended (the old `ANON_KEY` also works for backward compatibility).

```bash
# .env.local
NEXT_PUBLIC_SUPABASE_URL=<your-project-url>
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<your-publishable-key>
```

```bash
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 `getAll` and `setAll`. Never use `get` / `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](https://supabase.com/docs/guides/auth/server-side/creating-a-client)).

### 2.3 The Browser Client

```ts
// 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()`.

```ts
// 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.

```ts
// 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;
}
```

```ts
// 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.

```ts
// 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)](https://supabase.com/docs/guides/auth/server-side/nextjs) / [Creating a client](https://supabase.com/docs/guides/auth/server-side/creating-a-client) / [getClaims](https://supabase.com/docs/reference/javascript/auth-getclaims) / [JWT Signing Keys](https://supabase.com/docs/guides/auth/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](/blog/untrusted-client-postgres-rls-offline-first)." 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

```sql
-- まずテーブルでRLSを有効化（これを忘れると全公開）
alter table public.todos enable row level security;
```

The basic form of a policy is as follows.

```sql
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):

```sql
-- 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.

- `using` is "**which existing rows you may touch**" (SELECT/UPDATE/DELETE); `with check` is "**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's `sub`).
- `auth.jwt()` … returns the whole JWT as `jsonb`. You can traverse it like `auth.jwt() -> 'app_metadata'`.
  - **Put the data used for authorization in `app_metadata`, not `user_metadata`.** `user_metadata` is rewritable by the user themselves, so making it the basis for authorization allows privilege escalation.
- Enforce MFA (AAL2) with a **restrictive policy**:

```sql
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 | — |

```sql
-- ①インデックス
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 analyze` on 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.

```sql
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.

```sql
-- 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.

```sql
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](https://supabase.com/docs/guides/database/postgres/row-level-security) / [Database Functions](https://supabase.com/docs/guides/database/functions) / [RBAC (Custom Claims)](https://supabase.com/docs/guides/database/postgres/custom-claims-and-role-based-access-control-rbac)

---

## 4. Realtime — Broadcast, Presence, Postgres Changes

Realtime has three features ([realtime](https://supabase.com/docs/guides/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)

```ts
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**.

```sql
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();
```

```ts
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`).

```sql
-- 例：そのルームのメンバーだけ受信を許可
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)

```ts
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](https://supabase.com/docs/guides/realtime) / [Broadcast](https://supabase.com/docs/guides/realtime/broadcast) / [Subscribing to Database Changes](https://supabase.com/docs/guides/realtime/subscribing-to-database-changes) / [Realtime Authorization](https://supabase.com/docs/guides/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**.

```ts
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.serve` is **still supported**, but the official current best practice is the form of exporting `fetch` with `withSupabase`.

### 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.

```ts
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

```bash
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](https://supabase.com/docs/guides/functions/background-tasks)).
- **Region-specified invocation**: `supabase.functions.invoke('fn', { region: FunctionRegion.UsEast1 })`. Run in a region near the DB to cut latency ([regional-invocation](https://supabase.com/docs/guides/functions/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](https://supabase.com/docs/guides/functions) / [Quickstart](https://supabase.com/docs/guides/functions/quickstart) / [Auth in Functions](https://supabase.com/docs/guides/functions/auth) / [Limits](https://supabase.com/docs/guides/functions/limits)

---

## 6. Storage — Uploads, Signed URLs, and RLS

```ts
// アップロード（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).

```sql
-- 各ユーザーは「自分の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 also `SELECT` and `UPDATE` policies. This is the typical cause of "can upload but fails on overwrite."

> Primary source: [Storage](https://supabase.com/docs/guides/storage) / [Storage Access Control](https://supabase.com/docs/guides/storage/security/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.

```sql
-- 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`):

```sql
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;
$$;
```

```ts
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.

```sql
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](https://supabase.com/docs/guides/ai/automatic-embeddings)).

> Primary source: [AI & Vectors](https://supabase.com/docs/guides/ai) / [Vector Columns](https://supabase.com/docs/guides/ai/vector-columns) / [Semantic Search](https://supabase.com/docs/guides/ai/semantic-search) / [Vector Indexes](https://supabase.com/docs/guides/ai/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.

```bash
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](https://supabase.com/pricing)** (this article avoids asserting fixed values).

> Primary source: [Declarative Schemas](https://supabase.com/docs/guides/local-development/declarative-database-schemas) / [Migrations](https://supabase.com/docs/guides/deployment/database-migrations) / [Branching](https://supabase.com/docs/guides/deployment/branching) / [Read Replicas](https://supabase.com/docs/guides/platform/read-replicas) / [Cron](https://supabase.com/docs/guides/cron) / [Queues](https://supabase.com/docs/guides/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?** → use `getClaims()` (recommended) or `getUser()`.
- [ ] **Are you using `get`/`set`/`remove` for cookies?** → `getAll`/`setAll` only.
- [ ] **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 authenticated` in the policy?**
- [ ] **Did you put authorization data in `app_metadata`?** (`user_metadata` is tamperable).
- [ ] **Did you set `search_path = ''` on `security definer` functions?**
- [ ] **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](https://supabase.com/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.
