"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 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) and the RLS official docs (as of June 2026).
Premise: writing the RLS policy itself was handled in RLS getting started. 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.
npm install @supabase/supabase-js @supabase/ssr
# .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
// 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.
// 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 asauthenticatedcarrying the user's JWT." So theanonkey, notservice_role, is correct. RLS works properly. Usingservice_rolemeans 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.
// 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).
| 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.
// 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>
);
}
// 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_rolekey completely bypasses RLS. Never put it in the browser, a Client Component, aNEXT_PUBLIC_environment variable, or a public repository (Supabase official).
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 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 definerfunction if possible
// 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 usegetSession()'s return value for authorization. - RLS narrows even without writing
whereon the app side. But defend writes finally with WITH CHECK. - Don't expose
service_roleto the client. Structurally forbid it withimport "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 for when you still get stuck.