# Next.js App RouterでSupabase RLSを正しく効かせる：@supabase/ssr・サーバー/ブラウザクライアント・JWT伝播の完全ガイド

> 「Supabase RLSを書いたのにNext.jsだとデータが空で返る/全部見える」の原因は、ほぼクライアントの作り方です。@supabase/ssrのcreateBrowserClient/createServerClient、cookieのgetAll/setAll、middlewareのgetUser/getClaims、JWTがauth.uid()に伝わる仕組み、service_roleをクライアントで使ってはいけない理由まで、App Router前提で公式準拠の実コードで解説します。

- 公開日: 2026-06-28
- 著者: 友田 陽大
- タグ: Next.js, Supabase, RLS, TypeScript, セキュリティ
- URL: https://tomodahinata.com/blog/nextjs-app-router-supabase-rls-ssr-server-client-auth-guide
- カテゴリ: データベース・RLS
- 総合ガイド: https://tomodahinata.com/blog/supabase-production-guide-nextjs-rls-realtime-edge-functions

## 要点

- Next.jsでRLSが『効かない/空で返る』のは、ユーザーのJWTを運ばないクライアントでDBに接続しているから。RLSの問題ではなく統合（クライアント生成）の問題
- App Routerでは@supabase/ssrのcreateBrowserClient（クライアント）とcreateServerClient（サーバー）を使い分け、cookieのgetAll/setAllでセッションを橋渡しする。これでauthenticatedロールとして接続されauth.uid()が効く
- middlewareでsupabase.auth.getUser()（またはgetClaims()）を毎リクエスト呼び、トークンを更新してCookieに書き戻す。getSession()はlocal storage由来で再検証されないためサーバーで信頼しない
- service_roleキーはRLSを完全にバイパスする。NEXT_PUBLIC_配下やブラウザ・クライアントコンポーネントに絶対に置かない。クライアントはpublishable(anon)キーのみ
- RLSはサーバー側のwhere書き忘れに依存しない最終防衛線。だがNext.jsの結合を誤ると『RLSはあるのに毎回service_roleで握りつぶす』退行を生む。正しい結合こそがRLSを活かす

---

「Supabaseのダッシュボードでは正しく動くRLSが、Next.jsアプリからだと**データが空で返ってくる**」「逆に、本番で**全ユーザーのデータが見えてしまった**」——この2つは、Supabase + Next.jsで最も多い詰まりどころです。そして**原因のほぼ全ては、RLSポリシーそのものではなく、Next.js側の『Supabaseクライアントの作り方』にあります**。

RLSは「リクエストを送ってきたユーザーは誰か」を `auth.uid()` で見て行を絞ります。ということは、**そのユーザーのJWT（トークン）がDB接続まで正しく運ばれていなければ、RLSは判定材料を失います**。JWTを運ばないクライアントで繋げば「未認証（`anon`）」扱いになって空が返り、`service_role` キーで繋げばRLSをバイパスして全部見える。どちらも「RLSのバグ」ではなく「結合のミス」です。

この記事は、**Next.js App Router で RLS を確実に効かせるための、クライアント生成とJWT伝播の正しい型**を、公式の `@supabase/ssr` 前提で解説します。題材は、私が[リアルタイム試合記録アプリ](/case-studies/realtime-sports-scoring-app)を **Expo + Next.js + Supabase のモノレポ**（69テーブル全RLS・280ポリシー）で構築した実装です。内容は[Supabase公式: Server-Side Auth (Next.js)](https://supabase.com/docs/guides/auth/server-side/nextjs)・[RLS公式](https://supabase.com/docs/guides/database/postgres/row-level-security)（2026年6月時点）に忠実です。

> **前提**：RLSポリシーの書き方そのものは[RLS入門](/blog/supabase-rls-getting-started-enable-first-policy-guide)で扱いました。本記事は「正しく書けたRLSを、Next.jsから正しく呼ぶ」結合層に集中します。

---

## 1. 一枚の図で理解する：JWTはどこを通ってauth.uid()になるのか

まず全体像です。RLSが効くとは、次の鎖が**1箇所も切れずに**繋がっていることを意味します。

```
ブラウザ(Cookieにセッション)
  → Next.jsサーバー（middleware/Server Component）
    → @supabase/ssr が Cookie から JWT を読む
      → PostgREST へ Authorization: Bearer <JWT> として送る
        → Postgres が JWT を検証し authenticated ロールで実行
          → ポリシー内の auth.uid() が「そのユーザーのID」を返す
            → 行が正しく絞られる ✅
```

この鎖のどこかで JWT が落ちると、Postgres は「トークン無し＝`anon`」として実行し、`auth.uid()` は `null` を返します。`using ((select auth.uid()) = user_id)` は `null = user_id`（常に偽）になり、**1行も返らない**。これが「空で返る」の正体です。

つまり Next.js 側の仕事は1つ——**ブラウザのCookieにあるセッションを、サーバーでのDB接続まで途切れさせずに運ぶ**こと。`@supabase/ssr` はそのための公式パッケージです。

---

## 2. 2種類のクライアントを作り分ける

App Routerでは実行環境が「ブラウザ」と「サーバー」に分かれます。Supabaseクライアントも**環境ごとに別物**を作ります。混ぜると壊れます。

```bash
npm install @supabase/supabase-js @supabase/ssr
```

```bash
# .env.local — クライアントに出てよいのは publishable(anon) キーだけ
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your_publishable_key
# SUPABASE_SERVICE_ROLE_KEY=... ← NEXT_PUBLIC_を付けない。サーバーでも極力使わない
```

### ブラウザ用：Client Components から使う

```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!,
  );
```

### サーバー用：Server Components / Route Handlers / Server Actions から使う

サーバー側はCookieを読み書きしてセッションを橋渡しします。**`getAll`/`setAll` の両方**を実装するのが要点です。

```ts
// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export const createClient = async () => {
  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) {
          // Server Componentからの呼び出しでは書き込みが失敗し得る。
          // middlewareがセッションを更新するので、ここはtry/catchで握る。
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options),
            );
          } catch {
            // Server Componentでは無視してよい（middlewareが面倒を見る）
          }
        },
      },
    },
  );
};
```

> **なぜ `anon`（publishable）キーをサーバーでも使うのか**：このサーバークライアントは「サーバー上で動くが、**ユーザーのJWTを背負って `authenticated` として接続する**」ものです。だから `service_role` ではなく `anon` キーで正しい。RLSはちゃんと効きます。`service_role` を使うとRLSを捨てることになります（§5）。

---

## 3. middleware：毎リクエストでトークンを更新し、Cookieに書き戻す

`@supabase/ssr` の肝は middleware です。ここで**毎リクエスト、期限切れトークンを更新し、新しいセッションCookieをレスポンスに書き戻します**。これを置かないと、トークンが切れた瞬間に「空で返る」が再発します。

```ts
// middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

export async function middleware(request: NextRequest) {
  let response = 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) {
          // リクエストとレスポンスの両方にCookieを反映する（重要）
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value),
          );
          response = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options),
          );
        },
      },
    },
  );

  // ⚠️ これを必ず呼ぶ。トークンを更新し、JWT署名を検証する。
  await supabase.auth.getUser();

  return response;
}

export const config = {
  // 静的アセットを除外して、ページ遷移ごとに走らせる
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
```

### `getUser()` / `getClaims()` を使い、`getSession()` を信頼しない

ここはセキュリティの分かれ目です。Supabase公式は**サーバーコードで `getSession()` を信頼するな**と明言しています——「セッションはローカルストレージから読まれるだけで、Authサーバーに対して再検証されない」（[Supabase公式](https://supabase.com/docs/guides/auth/server-side/nextjs)）。

| API | 何をするか | サーバーで使う？ |
| --- | --- | --- |
| `getSession()` | Cookie/localStorageの中身をそのまま返す（**未検証**） | ❌ 認可判断に使わない |
| `getUser()` | Authサーバーに問い合わせ、ユーザーを**サーバー確認** | ✅ 確実だがネットワーク往復あり |
| `getClaims()` | **JWT署名を公開鍵で検証**してクレームを返す | ✅ 署名検証ベースで高速 |

最新のSupabase（非対称JWT＝公開鍵で検証可能）では、`getClaims()` が**プロジェクトの公開鍵に対してJWT署名を検証**するため、毎回Authサーバーに問い合わせずに安全な認可判断ができます。`getUser()` は「いま確実なユーザー情報」が欲しいときに使います。**`getSession()` の戻り値を認可の根拠にしてはいけません。**

---

## 4. 実際に使う：Server Component と Server Action

ここまで整えば、あとは普通に書くだけでRLSが効きます。**アプリ側で `where user_id = ...` を書く必要はありません**——RLSがやってくれます。

```tsx
// app/dashboard/page.tsx — Server Component
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  const supabase = await createClient();

  // 認可は getUser()/getClaims() で確認する
  const {
    data: { user },
  } = await supabase.auth.getUser();
  if (!user) redirect("/login");

  // ⬇ where句を書いていないのに、RLSが「自分の行だけ」に絞る
  const { data: matches, error } = await supabase
    .from("matches")
    .select("id, opponent, score")
    .order("created_at", { ascending: false });

  if (error) throw error; // RLSで弾かれた場合の挙動は次の章で扱う

  return (
    <ul>
      {matches?.map((m) => (
        <li key={m.id}>
          {m.opponent}: {m.score}
        </li>
      ))}
    </ul>
  );
}
```

```ts
// app/matches/actions.ts — Server Action
"use server";

import { createClient } from "@/lib/supabase/server";
import { revalidatePath } from "next/cache";
import { z } from "zod";

const CreateMatch = z.object({
  opponent: z.string().min(1).max(100),
  score: z.string().regex(/^\d+-\d+$/),
});

export async function createMatch(formData: FormData) {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();
  if (!user) throw new Error("unauthenticated");

  // 外部入力はサーバー境界でZod検証してから渡す
  const input = CreateMatch.parse({
    opponent: formData.get("opponent"),
    score: formData.get("score"),
  });

  // user_idを明示的に入れる。RLSのWITH CHECK ((select auth.uid()) = user_id) が
  // 「他人のuser_idを差し込む」のをDB側で弾く（多層防御）
  const { error } = await supabase
    .from("matches")
    .insert({ ...input, user_id: user.id });
  if (error) throw error;

  revalidatePath("/dashboard");
}
```

**設計のポイント**：アプリ層でも `user.id` を入れ、Zodで検証する。でもそれは「行儀の良さ」であって、**最後に守るのはDBのRLS（WITH CHECK）**です。アプリの検証を通り抜けても、RLSが他人の `user_id` を持つ行の作成を弾く。これが「アプリの善意に依存しない」多層防御です。

---

## 5. 最大の禁忌：service_role キーをクライアントで使わない

最後に、最も重大な事故を1章割いて潰します。

> **`service_role` キーは RLS を完全にバイパスします。** ブラウザ・Client Component・`NEXT_PUBLIC_` 環境変数・公開リポジトリに**絶対に置かないでください**（[Supabase公式](https://supabase.com/docs/guides/auth/server-side/nextjs)）。

なぜこれが頻発するか。開発中に「RLSのせいでデータが取れない」とき、`service_role` で繋ぐと**一発で取れてしまう**からです。これは問題解決ではなく**問題の隠蔽**で、RLSを書いた全努力を無に帰します。正しくは「なぜ `anon` 接続で取れないのか（＝JWTが運ばれていない）」を[トラブルシューティング](/blog/supabase-rls-troubleshooting-empty-results-insert-violation-not-working-guide)の手順で直すことです。

`service_role` を使ってよいのは、**RLSでは表現できない管理操作**（バッチ、Webhook受信、cron）を**サーバー専用の隔離された経路**で行うときだけ。その場合も：

- `NEXT_PUBLIC_` を付けない環境変数に置く（クライアントバンドルに入れない）
- Server Action / Route Handler の中だけで生成し、ブラウザに渡さない
- 使う箇所を最小化し、可能ならRLS＋`security definer`関数で代替する

```ts
// lib/supabase/admin.ts — サーバー専用。import元を厳格に管理する
import "server-only"; // クライアントからのimportをビルド時に禁止する
import { createClient } from "@supabase/supabase-js";

export const createAdminClient = () =>
  createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!, // NEXT_PUBLIC_ ではない
    { auth: { persistSession: false } },
  );
```

`import "server-only"` を先頭に置くと、誤ってClient Componentからimportした瞬間に**ビルドが失敗**します。「絶対にクライアントに出さない」を規律ではなく**型・ビルドで強制**する——これが事故を構造的に防ぐ設計です。

---

## まとめ：RLSを活かすも殺すも「結合」次第

- Next.jsで「RLSが効かない/空で返る」は、ほぼ**クライアント生成の誤り**。RLSのバグではない。
- `@supabase/ssr` で**ブラウザ用（createBrowserClient）とサーバー用（createServerClient）を作り分け**、CookieのgetAll/setAllでセッションを橋渡しする。
- **middlewareで `getUser()`/`getClaims()` を毎回呼び**、トークンを更新してCookieに書き戻す。`getSession()` の戻り値を認可に使わない。
- アプリ側で `where` を書かなくてもRLSが絞る。だが**書き込みはWITH CHECKで最終防衛**する。
- **`service_role` をクライアントに出さない**。`import "server-only"` で構造的に禁止する。

結合が正しければ、RLSは「サーバーの書き忘れ」に一切依存しない最終防衛線として、Next.jsアプリ全体を静かに守り続けます。次は、それでも詰まったときの[トラブルシューティング](/blog/supabase-rls-troubleshooting-empty-results-insert-violation-not-working-guide)へ。

### 一次情報（必ず最新を確認してください）

- [Supabase: Server-Side Auth for Next.js](https://supabase.com/docs/guides/auth/server-side/nextjs)
- [Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)
- [PostgreSQL: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
