- Accordion
- Action Sheet
- Alert Dialog
- Alert
- Audio Player
- Audio Recorder
- Audio Waveform
- Avatar
- AvoidKeyboard
- Badge
- BottomSheet
- Button
- Camera Preview
- Camera
- Card
- Carousel
- Checkbox
- Collapsible
- Color Picker
- Combobox
- Date Picker
- File Picker
- Gallery
- Hello Wave
- Icon
- Image
- Input OTP
- Input
- Link
- MediaPicker
- Mode Toggle
- Onboarding
- ParallaxScrollView
- Picker
- Popover
- Progress
- Radio
- ScrollView
- SearchBar
- Separator
- Share
- Sheet
- Skeleton
- Spinner
- Switch
- Table
- Tabs
- Text
- Toast
- Toggle
- Video
- View
Auth
OAuth opens the browser, then nothing happens
Your app's redirect URL is not in the allow-list. Authentication → URL Configuration → Redirect URLs needs both spellings, because Expo Go and a build produce different ones:
exp://localhost:8081
my-app://
To see exactly what your device produces:
import { makeRedirectUri } from 'expo-auth-session';
console.log(makeRedirectUri());That string, verbatim, has to be on the list.
Works in Expo Go, breaks in a build
Same cause, other direction — exp://localhost:8081 is allow-listed and
<scheme>:// is not. It also happens after changing scheme in app.json,
which the CLI sets to your project name at scaffold time.
redirect_uri_mismatch from Google or Apple
A different list. The provider needs Supabase's callback:
https://<your-project-ref>.supabase.co/auth/v1/callback
Supabase's list is where your app may receive the redirect afterwards. Both have to be right. See Google and Apple.
The user is signed out on every relaunch
Session persistence is failing. Almost always the storage adapter: expo-secure-store
refuses values over 2048 bytes and a real session is several kilobytes. This is
why the starter uses LargeSecureStore.
It is a nasty one to catch because a test account with no metadata can fit under the limit — so it works all through development and breaks on your first real user. See authentication.
Requests start failing after the app has been backgrounded
The access token expired and the refresh timer did not run — iOS suspends timers
in backgrounded apps. The fix is in lib/supabase.ts:
AppState.addEventListener('change', (state) => {
if (state === 'active') supabase.auth.startAutoRefresh();
else supabase.auth.stopAutoRefresh();
});Sign-in hangs with no error
detectSessionInUrl is true on native. There is no URL to parse, so the
client waits for a callback that never arrives. It must be
Platform.OS === 'web'.
Email not confirmed
Confirmations are on and the link has not been opened. That is correct
behaviour: supabase/config.toml turns them off locally for convenience, and
the hosted project has them on. The sign-up screen handles it —
if (!data.session) {
toast.success('Check your email', `We sent a confirmation link to ${email}.`);
router.replace('/sign-in');
}— so if you removed that branch, put it back.
The OTP screen has no code to enter
Your Magic Link email template has {{ .ConfirmationURL }} but not
{{ .Token }}. The default template omits the code. See
email.
No email arrives at all, and no error
The built-in SMTP rate limit, which is a handful of messages an hour. Configure your own provider.
The onboarding screen flashes for existing users
The guard is !profile?.onboarded instead of profile?.onboarded === false.
The profile row is created by a database trigger and is briefly null, which
the loose check reads as "not onboarded".
Database
A query returns an empty array with no error
RLS. An empty result is what a policy denial looks like from the client — there is no permission error, because the row simply is not visible.
Check in order:
Is there a policy for this operation and role at all?
A table with RLS enabled and no matching policy returns nothing. to authenticated policies do not apply to an anonymous caller.
Is the user actually signed in?
auth.uid() is null otherwise, and every auth.uid() = user_id comparison
fails.
Try it in the SQL editor as that user
set local role authenticated;
set local request.jwt.claims = '{"sub": "<user-id>"}';
select * from public.tasks;
reset role;An insert fails with "new row violates row-level security policy"
The with check expression on your insert policy is false. Usually the client
sent a user_id that is not auth.uid() — which is the policy doing its job.
Everything works, and anyone can read the table
RLS is not enabled. Policies on a table without it do nothing:
alter table public.your_table enable row level security;New tables have it off. This is the most consequential default in Postgres.
Queries got slow as the table grew
Every policy that filters on a column makes every query filter on it. Without an index that is a sequential scan:
create index tasks_user_id_created_at_idx
on public.tasks (user_id, created_at desc);db push says the migration is already applied
The remote history and your local files disagree — usually because someone changed the schema in Studio. Reconcile the history, then capture the Studio changes as a real migration:
pnpm dlx supabase migration repair --status applied <version> npx supabase db diff -f describe_what_changed
TypeScript does not know about a column I added
Regenerate: npm run db:types. The shipped CI has a job that fails when the
checked-in types drift from the migrations.
Realtime
The subscription connects but never fires
The table is not in the publication:
alter publication supabase_realtime add table public.tasks;Check what is:
select tablename from pg_publication_tables where pubname = 'supabase_realtime';DELETE events arrive with no data
replica identity full is missing. Postgres sends only the primary key
otherwise.
Rows appear twice
An optimistic insert plus the realtime echo. Deduplicate by id — that is what
applyChange in lib/realtime.ts does.
Or: the channel was subscribed twice because the effect cleanup does not call
supabase.removeChannel(channel).
The list is stale after the app was backgrounded
Events do not queue while the socket is closed. Refetch on reconnect:
.subscribe((status) => {
if (status === 'SUBSCRIBED') load();
});Storage
Upload fails with a 403
The bucket has no insert policy, or the object path does not match it. The owner-scoped policies key off the first path segment, so the path has to start with the user's id:
const path = `${user.id}/avatar.${extension}`;Upload fails with mime type not supported
contentType was not set, so it defaulted to application/octet-stream, which
is not in the bucket's allowed_mime_types.
Large images fail on Android
Base64. Use fetch(uri).then((r) => r.arrayBuffer()) — base64 is a third larger
and has to be held in memory whole.
The new avatar does not appear
The path is stable, so the URL is stable, so every cache in between keeps serving the old image. Add a cache-buster:
return `${publicUrl}?v=${Date.now()}`;Edge functions
tsc --noEmit fails on files under supabase/functions
They are Deno. Exclude them:
"exclude": ["node_modules", "supabase/functions"]invoke returns a 401
The function was deployed with JWT verification on and the caller has no session, or the token expired.
The function's queries return nothing
It is running as anon and RLS is filtering everything. Forward the caller's
header:
{
global: {
headers: {
Authorization: req.headers.get('Authorization')!;
}
}
}FAQ
Is the publishable key safe to ship?
Yes — that is what it is for. It grants exactly what your RLS policies allow the
anon role. Which is why those policies are the thing to review. A
sb_secret_… key is never safe to ship; it bypasses RLS entirely.
Do I need the Supabase CLI? No. The scaffold works without it and prints the commands instead. You need it to apply migrations, generate types or deploy functions — so in practice, yes.
Do I need Docker?
Only for supabase start. Linking a hosted project covers migrations, types and
functions with no Docker at all.
Can I use this in Expo Go? Yes, including OAuth — that is why the starter uses browser PKCE rather than native sign-in SDKs.
How do I add a table?
npx supabase migration new <name>, write the DDL plus enable row level security and its policies, npm run db:push, npm run db:types. See
database.
How do I add a sign-in provider?
Configure it in the dashboard, then add a line to the PROVIDERS array in
components/auth/oauth-buttons.tsx. All of them share one code path.
Why is there no callback route?
The deep link can arrive while any screen is mounted, and on a cold start before
the router has settled. providers/auth-provider.tsx handles it centrally so
none of that matters.
Can I use Supabase with the Convex starter? Pick one. They are both backends; running both means two sources of truth.
Migrating from Convex
Both are TypeScript-first backends with realtime queries, so the shapes map closely.
| Convex | Supabase |
|---|---|
convex/schema.ts | supabase/migrations/*.sql |
defineTable({...}) | create table … |
| Function-level auth checks | RLS policies, enforced by Postgres |
useQuery(api.tasks.list) | select + a postgres_changes subscription |
useMutation(api.tasks.add) | supabase.from('tasks').insert(...) |
query / mutation | Direct queries; edge functions for privileged work |
action | Edge function |
ctx.auth.getUserIdentity() | auth.uid() in a policy, getUser() in a function |
_generated/api.d.ts | lib/database.types.ts from supabase gen types |
ConvexAuthProvider | providers/auth-provider.tsx |
The real shift is where authorization lives. In Convex you check permissions inside each function; in Supabase the client talks to the database directly, so the check has to be a policy. A Convex function that forgot its check leaks through one endpoint — a Postgres table that forgot RLS leaks entirely.
Suggested order:
Translate the schema
One migration per Convex table, with RLS enabled and policies written at the same time. Never as a follow-up.
Move reads
Each useQuery becomes a select plus a subscription. hooks/useTasks.ts is
the pattern.
Move writes
Each useMutation becomes a direct query. The authorization that lived in the
function body is now the policy's with check.
Move actions
Anything calling a third-party API or needing a secret becomes an edge function.
Move auth last
ConvexAuthProvider → AuthProvider. Users do not transfer; plan a
re-registration or a scripted import via the admin API.
Still stuck
On This Page
AuthOAuth opens the browser, then nothing happensWorks in Expo Go, breaks in a buildredirect_uri_mismatch from Google or AppleThe user is signed out on every relaunchRequests start failing after the app has been backgroundedSign-in hangs with no errorEmail not confirmedThe OTP screen has no code to enterNo email arrives at all, and no errorThe onboarding screen flashes for existing usersDatabaseA query returns an empty array with no errorAn insert fails with "new row violates row-level security policy"Everything works, and anyone can read the tableQueries got slow as the table grewdb push says the migration is already appliedTypeScript does not know about a column I addedRealtimeThe subscription connects but never firesDELETE events arrive with no dataRows appear twiceThe list is stale after the app was backgroundedStorageUpload fails with a 403Upload fails with mime type not supportedLarge images fail on AndroidThe new avatar does not appearEdge functionstsc --noEmit fails on files under supabase/functionsinvoke returns a 401The function's queries return nothingFAQMigrating from ConvexStill stuck