Expo + Supabase

PreviousNext

Scaffold an Expo app with BNA UI and a Supabase backend — Postgres with row level security, realtime subscriptions, storage and an edge function, with no authentication.

Use this when you need a database, file storage and live updates, but not sign-in. Every table still has row level security enabled — "no auth" is not "no RLS", and the docs below explain why that distinction matters more here than in the auth starter.

pnpm dlx bna-ui supabase my-app --no-auth

Create a project

Create a Supabase project

At supabase.com/dashboard. Note the project reference — the subdomain in https://<ref>.supabase.co.

Run the CLI

pnpm dlx bna-ui supabase my-app --no-auth

It asks for your project URL and publishable key, both from Project Settings → API, then writes them to .env.local. Press enter at either prompt to skip and fill the file in yourself later.

It links, migrates and generates types

If the Supabase CLI is on your PATH, the scaffold runs these three for you. If it is not, they are printed as next steps instead — nothing fails.

pnpm dlx supabase link --project-ref your-project-ref
npx supabase db push
npm run db:types

Deploy the edge function

Optional; the settings tab calls it.

pnpm functions:deploy

Pass --skip-supabase to skip the prompts and all of the above.

Run it

pnpm dlx expo start

Three tabs, each demonstrating one thing: a live task list, a database query, and storage plus an edge function.

What you get

On top of the Expo starter:

lib/
├── supabase.ts            the client — no session, so nothing is persisted
├── realtime.ts            applyChange: the postgres_changes reducer
└── database.types.ts      generated; regenerate with `npm run db:types`
hooks/
├── useTasks.ts            select + subscription + optimistic CRUD
└── useUpload.ts           file URI → ArrayBuffer → storage → public URL
app/(tabs)/
├── (home)/index.tsx       live task list
├── search/index.tsx       `ilike` query against Postgres
└── settings/index.tsx     storage upload + edge function + connection status
supabase/
├── config.toml            local stack configuration
├── seed.sql               `supabase db reset` fixtures
├── migrations/
│   ├── 0001_tasks.sql     table, RLS, realtime publication, replica identity
│   └── 0002_storage.sql   public bucket + policies
└── functions/hello-world/index.ts
__tests__/realtime.test.ts jest-expo + the realtime reducer
.github/workflows/ci.yml   typecheck, test, type drift, deploy, EAS build

The client

There is no session to persist and nothing to refresh, so all three are off:

lib/supabase.ts
export const supabase = createClient<Database>(supabaseUrl, supabaseKey, {
  auth: {
    persistSession: false,
    autoRefreshToken: false,
    detectSessionInUrl: false,
  },
});

detectSessionInUrl must be false on native regardless of auth — there is no URL to parse, and leaving it on makes the client wait for a callback that never arrives.

Row level security

This is the part to read twice. The publishable key is compiled into your app bundle, so anyone with the app has it. Your RLS policies are the only thing standing between that key and your data.

The shipped migration enables RLS and then grants the anon role everything:

supabase/migrations/0001_tasks.sql
alter table public.tasks enable row level security;
 
create policy "Anyone can read tasks"
  on public.tasks for select
  to anon, authenticated
  using (true);

That is correct for a public demo and wrong for real data. Before you ship anything that matters, either tighten these policies or move to the auth starter, where every policy is scoped by auth.uid().

Full detail: database, RLS and migrations.

Realtime

Two lines in the migration do the work that is easy to miss:

supabase/migrations/0001_tasks.sql
alter publication supabase_realtime add table public.tasks;
alter table public.tasks replica identity full;

Without the first, a subscription connects successfully and then never fires — the single most common "realtime is broken" report. Without the second, UPDATE and DELETE events carry only the primary key.

The client side is hooks/useTasks.ts, and the reducer it uses is a pure function in lib/realtime.ts so it can be unit-tested without a database.

More: realtime.

Environment

VariableWhere it livesSet byUsed for
EXPO_PUBLIC_SUPABASE_URL.env.localbna-ui supabaseBuilding the client
EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY.env.localbna-ui supabaseBuilding the client
SUPABASE_SERVICE_ROLE_KEYEdge functionsSupabaseServer-side queries that bypass RLS

Supabase is retiring the legacy anon and service_role keys at the end of 2026. This starter uses the replacements, sb_publishable_… and sb_secret_…, throughout.

Local development

Everything above works against a hosted project. To run the whole stack on your machine instead — Postgres, storage, realtime, Studio and a mail catcher — install Docker and:

pnpm dlx supabase start          # Studio at http://localhost:54323
npx supabase db reset       # applies migrations, then seed.sql
npm run db:types:local

Point .env.local at the URL and key supabase start prints.

Next