DEV Community

Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on

Supabase returns an empty array even though the rows exist — here's the RLS fix

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!
Enter fullscreen mode Exit fullscreen mode

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:

  1. It works in the SQL Editor. The Supabase SQL Editor runs as a privileged role that bypasses RLS, so your SELECT * FROM projects returns the row — and you conclude the data layer is fine. It isn't; you just tested with the wrong role.
  2. The service role hides it in dev. If you accidentally used the service_role key 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.
  3. INSERT works but SELECT doesn't. You can have a policy for one operation and not another. A common shape: a WITH CHECK policy lets writes through, but there's no USING policy 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 );
Enter fullscreen mode Exit fullscreen mode

Key parts:

  • for select — this policy only governs reads. You'll need separate policies for insert, update, delete.
  • to authenticated — applies to logged-in users (not the anon role).
  • using ( auth.uid() = user_id ) — the row is visible only when the requesting user's ID matches the row's user_id column.

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()
  )
);
Enter fullscreen mode Exit fullscreen mode

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:

  1. From your app, with the anon/authenticated key, logged in as a real user. The rows should now appear.
  2. Check auth.uid() is actually populated. If your server client isn't reading the session cookie correctly, auth.uid() is null and 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/ssr server client, not the browser client.
  3. 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)