You query a Supabase table from your Next.js app. The row is right there in the Table Editor. But your code gets back an empty array:
const { data, error } = await supabase.from('projects').select('*')
// error: null
// data: [] ← but the row exists!
No error. No 401. Just nothing. This is one of the most common "it works on localhost, breaks in prod" bugs with Supabase — and almost every time, the cause is the same.
TL;DR
Row Level Security (RLS) is enabled on the table, but there is no SELECT policy that matches the current role. With RLS on and no matching policy, Postgres returns zero rows and no error — that's by design. The fix is to add a scoped SELECT policy and test it as the real authenticated user.
Why it happens
When you toggle "Enable RLS" on a table (or use a template that does it for you), Postgres switches that table to deny-by-default. Every query is now filtered by policies. If no policy grants SELECT to the role making the request, the result set is empty.
The trap: it is not an error. A blocked row looks identical to a row that doesn't exist. So you go hunting in your query, your .eq() filters, your network tab — when the real problem is one missing policy.
Three things make this worse:
-
It works in the SQL Editor. The Supabase SQL Editor runs as a privileged role that bypasses RLS, so your
SELECT * FROM projectsreturns the row — and you conclude the data layer is fine. It isn't; you just tested with the wrong role. -
The service role hides it in dev. If you accidentally used the
service_rolekey on the client during early development, RLS was bypassed and everything "worked." The day you switch to the anon/authenticated key (as you must), the rows vanish. -
INSERTworks butSELECTdoesn't. You can have a policy for one operation and not another. A common shape: aWITH CHECKpolicy lets writes through, but there's noUSINGpolicy for reads — so you can insert a row you can never read back.
The fix
Add a SELECT policy scoped to the authenticated user. The most common multi-tenant pattern:
-- Allow users to read only their own rows
create policy "Users can read their own projects"
on public.projects
for select
to authenticated
using ( auth.uid() = user_id );
Key parts:
-
for select— this policy only governs reads. You'll need separate policies forinsert,update,delete. -
to authenticated— applies to logged-in users (not theanonrole). -
using ( auth.uid() = user_id )— the row is visible only when the requesting user's ID matches the row'suser_idcolumn.
If users should read rows belonging to their organization instead of just themselves:
create policy "Members can read their org's projects"
on public.projects
for select
to authenticated
using (
org_id in (
select org_id from public.memberships
where user_id = auth.uid()
)
);
How to verify (the part people skip)
Don't re-test in the SQL Editor — it bypasses RLS and will lie to you. Test the way your app actually queries:
- From your app, with the anon/authenticated key, logged in as a real user. The rows should now appear.
-
Check
auth.uid()is actually populated. If your server client isn't reading the session cookie correctly,auth.uid()isnulland the policy fails for a different reason (an SSR/cookie bug, not a policy bug). In a Next.js Server Component, make sure you're using the@supabase/ssrserver client, not the browser client. -
Confirm you're not still using the service role on the client. Search your code for
SERVICE_ROLE— it should never reach the browser.
The mental model to keep
With RLS enabled, no policy = no rows, and no error. An empty array is Postgres saying "you're not allowed to see this," not "this doesn't exist."
Once that clicks, this bug goes from a two-hour mystery to a thirty-second check: Is RLS on? Is there a SELECT policy? Does auth.uid() resolve? Am I testing as the real role?
I keep a symptom-first index of these Next.js + Supabase production bugs — empty arrays, SSR session loss, OAuth callback loops, Stripe webhook duplicates, stale cache after Server Actions — each mapped to a root cause, a fix, and a reusable SQL/checklist asset. It's open and I'm collecting the incidents that burned other people: Awesome Next.js + Supabase.
What's the Next.js/Supabase production bug that cost you the most time?
Top comments (0)