You've connected Supabase to your Rork app, added data to the database, and hit run. The screen loads. But the list is empty. No crash. No error toast. Just a blank view where your records should be.
This is one of the more disorienting situations in mobile development, because the app looks fine. Nothing tells you something is wrong. You start second-guessing everything — did the insert work? Is the query reaching the database? Is the component even re-rendering?
I've hit this wall enough times to know that almost every case comes down to one of four root causes. This guide walks through each one with specific diagnostic steps and code you can apply directly.
Pattern 1: Row Level Security (RLS) — The Most Common Culprit
If I had to bet, RLS misconfiguration is behind more than half of all "Supabase data not loading" problems. When you enable RLS on a table in Supabase — which happens by default when you create tables through the dashboard — all access is denied until you explicitly write policies to allow it.
This is a security-first design. It's genuinely a good feature. But for developers who are new to Supabase, it produces exactly the symptom we're describing: data in the database, nothing in the app, no errors.
How to diagnose
Open the Supabase dashboard and go to the Table Editor. Browse your table directly from there. If data shows up in the dashboard but not in your app, the issue is almost certainly RLS.
Next, go to Authentication → Policies. If your table has RLS enabled but zero policies defined, every query — regardless of who's asking — returns an empty result set.
-- During development, you can temporarily allow all reads to confirm the issue:
-- (Do NOT leave this in production)
create policy "Allow all reads for testing"
on your_table
for select
using (true);
-- Production-ready policy: only return rows belonging to the current user
create policy "Users can read their own data"
on your_table
for select
using (auth.uid() = user_id);The auth.uid() function is built into Supabase. It returns the UUID of the currently authenticated user. If your table has a user_id column, this policy will only return rows where user_id matches the logged-in user — which is exactly what you want for user-specific data.
Fetching data before authentication completes
There's a subtler RLS issue worth calling out: if your component fetches data immediately on mount, before the user's Supabase session is fully established, the query will be treated as an unauthenticated request and RLS will block it.
// ❌ Runs immediately on mount — auth may not be ready yet
useEffect(() => {
fetchData();
}, []);
// ✅ Wait for auth state before fetching
useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => {
if (session) {
fetchData(); // guaranteed to run when a valid session exists
}
}
);
return () => subscription.unsubscribe();
}, []);This is especially worth checking if your app works fine after a manual refresh but fails on first load. The timing of session restoration on app boot is often the culprit.
Pattern 2: Wrong Supabase URL or API Key
This sounds too simple to be the issue, but I've seen it trip up developers at every experience level. It's easy to paste the wrong key from the dashboard, mix up keys between projects, or have a .env file that isn't loading correctly.
What to verify
// supabase.ts
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL\!;
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY\!;
// Add this during debugging to confirm env vars are actually loaded
console.log('Supabase URL present:', \!\!supabaseUrl);
console.log('Supabase key length:', supabaseAnonKey?.length);
export const supabase = createClient(supabaseUrl, supabaseAnonKey);Critical detail for Expo / React Native: environment variables used in client-side code must start with EXPO_PUBLIC_. Without this prefix, the variable is undefined at runtime and your Supabase client will fail to initialize — usually without a helpful error message.
The two keys and when to use which: Supabase provides an anon key (safe for client apps) and a service_role key (server-only, bypasses all RLS). Using the anon key in your app is correct. Using the service_role key in a mobile app is a serious security risk — anyone who decompiles the app can extract it and gain unrestricted database access. If you've been using the service role key and suddenly switch to the anon key, your data may disappear behind RLS policies you've never had to set up.
Head to Settings → API in your Supabase dashboard to get the correct values, and compare them character-by-character to what's in your .env file.
See Rork environment variable configuration troubleshooting for more on Expo's env variable behavior.
Pattern 3: Async Handling Mistakes
When RLS and API keys check out, look at how you're handling the asynchronous Supabase query. Missing await is the classic mistake — and it produces exactly the "data is undefined, no error" symptom because the Promise object isn't null, it's just not resolved yet.
The most common mistake
// ❌ No await — data will always be undefined here
const fetchData = () => {
const { data, error } = supabase
.from('posts')
.select('*');
// `data` at this point is a Promise, not actual records
setData(data);
};
// ✅ Properly awaited async function
const fetchData = async () => {
const { data, error } = await supabase
.from('posts')
.select('*');
if (error) {
console.error('Fetch error:', error.message);
return;
}
setData(data ?? []); // convert null to empty array
};State initialization and render timing
A related issue: initializing state with null instead of an empty array. When the component renders before data arrives — which is always on first mount — calling .map() on null throws a runtime error.
// ❌ Initializing with null causes a crash when .map() is called
const [posts, setPosts] = useState(null);
// ...
return <FlatList data={posts} ... />; // crash: posts is null
// ✅ Initialize with an empty array, show loading state explicitly
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetch = async () => {
setLoading(true);
const { data, error } = await supabase
.from('posts')
.select('id, title, body, created_at')
.order('created_at', { ascending: false });
if (error) {
setError(error.message);
} else {
setPosts(data ?? []);
}
setLoading(false);
};
fetch();
}, []);
if (loading) return <ActivityIndicator size="large" />;
if (error) return <Text>Error: {error}</Text>;
if (posts.length === 0) return <Text>No posts yet</Text>;
return (
<FlatList
data={posts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <PostCard post={item} />}
/>
);Showing an explicit loading state also helps with debugging: if you see the spinner for longer than expected, you know the fetch is running but not completing, which points toward a network or RLS issue rather than an async handling mistake.
Pattern 4: Table or Column Name Typos
This is the most humbling one. Supabase table and column names are case-sensitive and conventionally snake_case. If you write userProfiles instead of user_profiles, Supabase returns an error like relation "userProfiles" does not exist — but if you're not logging errors, you'll just see empty data.
// ❌ Wrong names — these won't match
const { data, error } = await supabase
.from('userProfiles') // probably "user_profiles" in Supabase
.select('userName'); // probably "user_name" in Supabase
if (error) {
// Without this, you'd never know the query failed
console.error(error.message); // "relation userProfiles does not exist"
}
// ✅ Exact names from the Supabase Table Editor
const { data, error } = await supabase
.from('user_profiles')
.select('user_name, avatar_url, bio, created_at');The fastest fix: open the Table Editor in the Supabase dashboard, find your table, and read the exact column names from there. Then start with select('*') to get the full response shape, log it, and build your select statement from what you see in the logs.
Diagnostic Checklist — Work Through This Sequence
When you're stuck and don't know where to start:
1. Log the error explicitly.
const { data, error } = await supabase.from('your_table').select('*');
console.log('data:', data);
console.log('error:', error);If error is null and data is an empty array, suspect RLS. If error contains "Invalid API key," that's Pattern 2. If it says "relation does not exist," that's Pattern 4.
2. Check Supabase API Logs in real time. Under Logs → API Logs in the dashboard, you can see every request, its status code, and the query that ran. A 406 response means RLS blocked the request. A 400 often means a malformed query.
3. Temporarily disable RLS. Go to Authentication → Policies, turn off RLS for the table, and test again. If data loads immediately, you need to write or fix your policies. Turn RLS back on before deploying.
4. Test the query directly in Supabase's SQL editor. You can run select * from your_table limit 10; directly from the dashboard and compare the result to what your app receives.
For deeper backend troubleshooting, Rork database connection error diagnosis covers connectivity issues at the infrastructure level, and Supabase auth error fixes goes deeper on authentication edge cases.
Moving Forward
The pattern I'd recommend when starting any new Supabase integration: get data flowing first with RLS temporarily disabled and logging enabled everywhere, then layer in security policies one at a time, verifying each step doesn't break the query. It's slower upfront but saves the frustration of debugging two systems at once.
Once you've worked through one RLS debugging cycle, the mental model becomes much clearer. You start to intuitively understand what's happening when a query returns empty, and future issues take minutes instead of hours to resolve.