# Making Supabase RLS work correctly in the Next.js App Router: a complete guide to @supabase/ssr, server/browser clients, and JWT propagation

> The cause of 'I wrote Supabase RLS but in Next.js data comes back empty / everything is visible' is almost always how the client is created. Premised on the App Router, with official-compliant real code it explains @supabase/ssr's createBrowserClient/createServerClient, the cookie getAll/setAll, middleware's getUser/getClaims, the mechanism by which the JWT reaches auth.uid(), and why you must not use service_role on the client.

- Published: 2026-06-28
- Author: 友田 陽大
- Tags: Next.js, Supabase, RLS, TypeScript, セキュリティ
- URL: https://tomodahinata.com/en/blog/nextjs-app-router-supabase-rls-ssr-server-client-auth-guide
- Category: Databases & RLS
- Pillar guide: https://tomodahinata.com/en/blog/supabase-production-guide-nextjs-rls-realtime-edge-functions

## Key points

- RLS 'not working / returning empty' in Next.js is because you're connecting to the DB with a client that doesn't carry the user's JWT. It's not an RLS problem but an integration (client creation) problem.
- In the App Router, use @supabase/ssr's createBrowserClient (client) and createServerClient (server) separately, and bridge the session with cookie getAll/setAll. This connects as the authenticated role and auth.uid() works.
- Call supabase.auth.getUser() (or getClaims()) every request in middleware, refresh the token, and write it back to the Cookie. getSession() is local-storage-derived and not re-verified, so don't trust it on the server.
- The service_role key completely bypasses RLS. Never put it under NEXT_PUBLIC_ or in the browser / client components. The client uses only the publishable (anon) key.
- RLS is the last line of defense that doesn't depend on a forgotten where on the server side. But mistake the Next.js integration and you create the regression of 'RLS exists but every time you swallow it with service_role.' Correct integration is what makes RLS effective.

---

"RLS that works correctly in the Supabase dashboard **returns empty data** from a Next.js app," "conversely, in production **all users' data became visible**" — these two are the most common sticking points with Supabase + Next.js. And **almost all of the cause is not the RLS policy itself but the Next.js side's 'how the Supabase client is created.'**

RLS narrows rows by looking at "who is the user that sent the request" with `auth.uid()`. That means **if that user's JWT (token) isn't correctly carried all the way to the DB connection, RLS loses its material for judgment.** Connect with a client that doesn't carry the JWT and you're treated as "unauthenticated (`anon`)" and empty is returned; connect with the `service_role` key and you bypass RLS and see everything. Neither is "an RLS bug" but "an integration mistake."

This article explains, premised on the official `@supabase/ssr`, **the correct pattern of client creation and JWT propagation to make RLS reliably work in the Next.js App Router.** The material is my implementation building [a real-time match-recording app](/case-studies/realtime-sports-scoring-app) as an **Expo + Next.js + Supabase monorepo** (all RLS on 69 tables, 280 policies). The content is faithful to [Supabase official: Server-Side Auth (Next.js)](https://supabase.com/docs/guides/auth/server-side/nextjs) and the [RLS official docs](https://supabase.com/docs/guides/database/postgres/row-level-security) (as of June 2026).

> **Premise**: writing the RLS policy itself was handled in [RLS getting started](/blog/supabase-rls-getting-started-enable-first-policy-guide). This article concentrates on the integration layer of "correctly calling correctly-written RLS from Next.js."

---

## 1. Understand it in one diagram: where does the JWT pass through to become auth.uid()

First, the big picture. RLS working means the following chain is connected **without a single break.**

```
Browser (session in Cookie)
  → Next.js server (middleware/Server Component)
    → @supabase/ssr reads the JWT from the Cookie
      → sends to PostgREST as Authorization: Bearer <JWT>
        → Postgres verifies the JWT and runs as the authenticated role
          → auth.uid() in the policy returns "that user's ID"
            → rows are correctly narrowed ✅
```

If the JWT drops somewhere in this chain, Postgres runs as "no token = `anon`" and `auth.uid()` returns `null`. `using ((select auth.uid()) = user_id)` becomes `null = user_id` (always false), and **not a single row is returned.** This is the true identity of "returns empty."

So the Next.js side's job is one — **carry the session in the browser's Cookie to the DB connection on the server without interruption.** `@supabase/ssr` is the official package for that.

---

## 2. Create two kinds of clients separately

In the App Router, the execution environment splits into "browser" and "server." The Supabase client too is **a different thing per environment.** Mix them and it breaks.

```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_を付けない。サーバーでも極力使わない
```

### For the browser: use from 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!,
  );
```

### For the server: use from Server Components / Route Handlers / Server Actions

The server side reads and writes Cookies to bridge the session. The key point is implementing **both `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が面倒を見る）
          }
        },
      },
    },
  );
};
```

> **Why use the `anon` (publishable) key on the server too**: this server client is "runs on the server, but **connects as `authenticated` carrying the user's JWT.**" So the `anon` key, not `service_role`, is correct. RLS works properly. Using `service_role` means throwing away RLS (§5).

---

## 3. middleware: refresh the token every request and write it back to the Cookie

The crux of `@supabase/ssr` is the middleware. Here you **refresh expired tokens every request and write the new session Cookie back to the response.** Without this, "returns empty" recurs the moment the token expires.

```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).*)"],
};
```

### Use `getUser()` / `getClaims()`, don't trust `getSession()`

This is the divide of security. The Supabase official explicitly says **don't trust `getSession()` in server code** — "the session is only read from local storage and not re-verified against the Auth server" ([Supabase official](https://supabase.com/docs/guides/auth/server-side/nextjs)).

| API | What it does | Use on server? |
| --- | --- | --- |
| `getSession()` | returns the Cookie/localStorage contents as-is (**unverified**) | ❌ don't use for authorization judgment |
| `getUser()` | queries the Auth server and **server-confirms** the user | ✅ certain but with a network round trip |
| `getClaims()` | **verifies the JWT signature with the public key** and returns the claims | ✅ signature-verification-based and fast |

In the latest Supabase (asymmetric JWT = verifiable with a public key), `getClaims()` **verifies the JWT signature against the project's public key**, so it can make a safe authorization judgment without querying the Auth server each time. Use `getUser()` when you want "certain user info right now." **Don't make `getSession()`'s return value the basis of authorization.**

---

## 4. Actually use it: Server Component and Server Action

Once this is set up, after that just write normally and RLS works. **You don't need to write `where user_id = ...` on the app side** — RLS does it.

```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");
}
```

**The design point**: put `user.id` on the app layer too and validate with Zod. But that's "good manners," and **what protects last is the DB's RLS (WITH CHECK).** Even if it slips past the app's validation, RLS rejects creating a row with someone else's `user_id`. This is the defense-in-depth that "doesn't depend on the app's goodwill."

---

## 5. The biggest taboo: don't use the service_role key on the client

Finally, I'll devote a chapter to crushing the most serious accident.

> **The `service_role` key completely bypasses RLS.** **Never put it** in the browser, a Client Component, a `NEXT_PUBLIC_` environment variable, or a public repository ([Supabase official](https://supabase.com/docs/guides/auth/server-side/nextjs)).

Why does this happen frequently. Because during development, when "data can't be fetched because of RLS," connecting with `service_role` **fetches it in one go.** This is not problem-solving but **concealing the problem**, nullifying all the effort of writing RLS. The correct way is to fix "why can't it be fetched on an `anon` connection (= the JWT isn't carried)" with the [troubleshooting](/blog/supabase-rls-troubleshooting-empty-results-insert-violation-not-working-guide) procedure.

You may use `service_role` only when performing **admin operations RLS can't express** (batch, webhook reception, cron) on a **server-only isolated path.** Even then:

- put it in an environment variable without `NEXT_PUBLIC_` (don't put it in the client bundle)
- create it only inside a Server Action / Route Handler, and don't pass it to the browser
- minimize where it's used, and substitute with RLS + a `security definer` function if possible

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

Place `import "server-only"` at the top and the **build fails** the moment it's mistakenly imported from a Client Component. Enforcing "never expose to the client" not by discipline but by **type/build** — this is the design that structurally prevents the accident.

---

## Conclusion: whether RLS lives or dies depends on "integration"

- "RLS not working / returning empty" in Next.js is almost always **a client-creation mistake.** Not an RLS bug.
- With `@supabase/ssr`, **create the browser one (createBrowserClient) and the server one (createServerClient) separately**, and bridge the session with the Cookie getAll/setAll.
- **Call `getUser()`/`getClaims()` every time in middleware**, refresh the token, and write it back to the Cookie. Don't use `getSession()`'s return value for authorization.
- RLS narrows even without writing `where` on the app side. But **defend writes finally with WITH CHECK.**
- **Don't expose `service_role` to the client.** Structurally forbid it with `import "server-only"`.

If the integration is correct, RLS keeps quietly protecting the whole Next.js app as a last line of defense that doesn't depend at all on "a forgotten write on the server." Next, on to the [troubleshooting](/blog/supabase-rls-troubleshooting-empty-results-insert-violation-not-working-guide) for when you still get stuck.

### Primary sources (always confirm the latest)

- [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)
