← All posts

How to connect Supabase to a React Native Expo template with the Supabase MCP server

Step-by-step: add a real Supabase backend (auth, Postgres, RLS, storage) to an Expo React Native template using Claude Code and the Supabase MCP server. Schema, security checks and typed client included.

September 24, 2026 · Thomino

Every one of our templates ships as front end only. The login screen looks finished, the profile screen has an avatar picker, the lists are full of content, and none of it talks to a server. That's on purpose: you pick the backend, not us.

This post shows how quickly that backend goes from "later" to "done". I took the Front-End Starter, opened it in Claude Code, connected the Supabase MCP server, and ended up with real email sign-up, a Postgres database with row level security, file uploads and generated TypeScript types. The front end barely changed, because the screens were already built.

To keep it concrete, the example here is a small reading list app: users sign up, save links, and organise them into collections. Swap in your own tables; the steps are the same.

Why Supabase for a React Native app

Supabase is Postgres with the parts a mobile app needs around it:

  • Auth with email, magic links, Apple and Google, and sessions that persist on the device.
  • A REST API generated from your tables, so there's no server code for basic reads and writes.
  • Row level security (RLS), which lets the app talk to the database directly while each user only sees their own rows.
  • Storage for avatars and uploads, protected by the same kind of policies.
  • supabase-js, which works in Expo with AsyncStorage and no native modules.

For a template buyer that last point matters. You don't need to eject, add native code or rebuild the dev client to get a working backend.

What the Supabase MCP server does

MCP (Model Context Protocol) is how coding agents like Claude Code and Cursor talk to external tools. The Supabase MCP server gives the agent direct, authenticated access to your Supabase project. With it connected, the agent can:

  • list your tables and read the schema before changing anything,
  • apply migrations (tables, policies, functions),
  • run SQL to test what it built,
  • run Supabase's security and performance advisors and fix what they flag,
  • fetch your project URL and publishable key,
  • generate TypeScript types from the live schema,
  • search the current Supabase docs instead of relying on memory.

That last one sounds minor and isn't. Supabase changes often (new key formats, deprecated helpers, new defaults), and an agent working from training data will happily write last year's code. With the docs tool it checks first.

The practical result: you describe the backend in plain language and review what it did, instead of copying SQL between a dashboard and your editor.

Step 1: Create a Supabase project

Create a project at supabase.com. The free plan is enough for development. Note the project ref, the short id in your project URL (https://<project-ref>.supabase.co).

Step 2: Add the Supabase MCP server to your project

Supabase gives you a ready-made command in the dashboard (Connect → MCP). For Claude Code it looks like this:

claude mcp add --scope project --transport http supabase \
  "https://mcp.supabase.com/mcp?project_ref=<project-ref>&features=docs%2Caccount%2Cdatabase%2Cdebugging%2Cdevelopment%2Cfunctions%2Cbranching"

--scope project writes the config to .mcp.json in your repo, so everyone who opens the project gets the same server. project_ref pins the server to one project, which is what you want: the agent can't touch your other projects by accident.

Then authenticate. In a regular terminal (not an IDE panel), start Claude Code and run:

/mcp

Select supabase, choose Authenticate and finish the login in your browser. Back in the terminal the Supabase tools are now available.

Optionally, install Supabase's agent skills. They're instructions the agent loads for Supabase work: security rules, migration workflow, and common mistakes to avoid.

npx skills add supabase/agent-skills

Cursor uses the same MCP URL; add it under Settings → MCP and authenticate there.

Step 3: Let the agent read the template first

Before any backend work, the agent should know what the app expects. Our templates include a CLAUDE.md that describes the stack, the folder structure and the rules, including this line:

All data is local mock data — there is no backend, no real auth (AuthContext holds a hard-coded mock profile in memory).

That tells the agent exactly where the seam is. Auth lives in one context. Screens call useAuth() and render profile. Replace the inside of that context and the screens don't need to change. (More on why this matters for agents in Starting a React Native app with Claude Code or Cursor.)

A good first prompt:

Read CLAUDE.md and the auth screens. Then check the Supabase project with the MCP tools: list tables and extensions. Don't change anything yet — tell me what you'd build for a reading list app where users save links into collections.

Reading before writing avoids most of the rework.

Step 4: Design the schema and security in one migration

Here's the prompt that does the real work:

Create the schema for the reading list app as a migration: a profiles table linked to auth.users and created automatically on sign-up, collections owned by a user, and saved_links belonging to a collection. Enable RLS on every table so users can only read and write their own rows. Add an avatars storage bucket where users can only upload into their own folder. Show me the SQL before applying it.

The agent produced something close to this (trimmed):

create table public.profiles (
  id           uuid primary key references auth.users (id) on delete cascade,
  display_name text check (char_length(display_name) <= 80),
  avatar_url   text,
  created_at   timestamptz not null default now()
);
 
create table public.collections (
  id         uuid primary key default gen_random_uuid(),
  user_id    uuid not null references public.profiles (id) on delete cascade,
  name       text not null check (char_length(name) between 1 and 60),
  created_at timestamptz not null default now()
);
create index collections_user_id_idx on public.collections (user_id);
 
create table public.saved_links (
  id            uuid primary key default gen_random_uuid(),
  user_id       uuid not null references public.profiles (id) on delete cascade,
  collection_id uuid not null references public.collections (id) on delete cascade,
  url           text not null,
  title         text,
  created_at    timestamptz not null default now()
);
create index saved_links_collection_id_idx on public.saved_links (collection_id);
create index saved_links_user_id_idx on public.saved_links (user_id);
 
alter table public.profiles    enable row level security;
alter table public.collections enable row level security;
alter table public.saved_links enable row level security;
 
-- Owners can do everything with their own rows, nobody else sees them.
create policy "Owners manage their collections"
  on public.collections for all
  to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);

Plus a trigger that inserts a profiles row whenever someone signs up, and storage policies that check the first folder of the file path equals the user's id.

A few details worth noticing, because they're the ones people get wrong by hand:

  • to authenticated plus an ownership check. to authenticated alone only checks that someone is logged in. Without auth.uid() = user_id, every user could read every other user's data.
  • with check on writes. Without it, a user could update a row and change its user_id to someone else's.
  • (select auth.uid()) instead of auth.uid(). Wrapping it in a select lets Postgres evaluate it once per query instead of once per row. On large tables this is the difference between fast and slow.
  • Indexes on the columns the policies filter on. RLS adds a where clause to every query; it needs an index like any other filter.
  • The sign-up trigger runs as security definer in a private schema. It has to write to profiles on behalf of the auth system, but it shouldn't be callable from the app.

The agent applies the migration with the MCP apply_migration tool, which also records it in the project's migration history.

Step 5: Check it instead of trusting it

This is where MCP pays for itself. Ask:

Run the security and performance advisors. Then test the RLS policies: create two test users inside a transaction, have user A create a collection, and confirm user B can't read, update or delete it, and that anonymous users see nothing. Roll everything back afterwards.

The agent runs the advisors (in our case: one missing index on a foreign key, fixed with a second small migration) and then runs a SQL block that impersonates each user by setting the JWT claims inside a transaction, tries each operation, and reports the results:

A creates collection     ok
B reads A's collection   0 rows
B updates A's row        0 rows
B inserts into A's list  denied
anon reads collections   0 rows

Then it raises an error on purpose so the transaction rolls back and no test data is left behind.

You'd normally do this by clicking around two accounts in a simulator. Here it takes one prompt and covers the cases you'd forget.

Step 6: Connect the Expo app

Now the front end. Install the client:

npx expo install @supabase/supabase-js react-native-url-polyfill

Ask the agent to fetch the project URL and publishable key with the MCP tools and write them to .env.local:

EXPO_PUBLIC_SUPABASE_URL=https://<project-ref>.supabase.co
EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...

The publishable key is designed to ship inside the app. It can only do what your RLS policies allow, which is why step 4 matters. Never put the secret (service role) key in a mobile app; anything in the bundle can be extracted.

The client, following Supabase's current React Native guide:

// lib/supabase.ts
import 'react-native-url-polyfill/auto';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient, processLock } from '@supabase/supabase-js';
import { AppState, Platform } from 'react-native';
import type { Database } from './database.types';
 
export const supabase = createClient<Database>(
  process.env.EXPO_PUBLIC_SUPABASE_URL!,
  process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
  {
    auth: {
      ...(Platform.OS !== 'web' ? { storage: AsyncStorage } : {}),
      autoRefreshToken: true,
      persistSession: true,
      detectSessionInUrl: false,
      lock: processLock,
    },
  }
);
 
// Only refresh the session while the app is in the foreground.
AppState.addEventListener('change', (state) => {
  if (state === 'active') supabase.auth.startAutoRefresh();
  else supabase.auth.stopAutoRefresh();
});

database.types.ts comes from the MCP generate_typescript_types tool. Every query is now typed against your real schema: a typo in a column name is a compile error, not a runtime bug.

Step 7: Replace the mock auth with real auth

In the template, AuthContext holds a hard-coded profile. The agent rewrote it to:

  • load the stored session on launch and listen to onAuthStateChange,
  • fetch the signed-in user's row from profiles,
  • expose signIn, signUp, signOut, resetPassword, updateProfile and uploadAvatar.

The existing screens only needed their buttons wired up. The login screen already had email and password inputs and a loading state on the button; onPress now calls signIn and shows the error message if it fails. The edit profile screen already had an image picker; the chosen photo now uploads to the avatars bucket before the profile is saved.

The one new piece is an auth gate in the root layout: signed-out users go to the welcome screen, signed-in users skip the login screens. Around twenty lines with Expo Router's useSegments.

Step 8: Test the whole flow for real

Before touching a simulator, ask the agent to test end to end against the live project:

Sign up a throwaway user through the Auth API, update their profile, upload an avatar into their folder, try to upload into someone else's folder, then delete the user and the file.

Expected results: sign-up returns a session, the profile row exists, your own upload works, the other upload is rejected by the storage policy, and cleanup leaves the project empty. Then run the app and sign up for real.

What you end up with

Starting from a template with finished screens and no backend:

  • email sign-up, login, logout and password reset,
  • a Postgres schema with row level security on every table, tested,
  • avatar uploads to Supabase Storage,
  • migration files in your repo,
  • TypeScript types generated from the live database,
  • an updated CLAUDE.md, so the next agent session knows there's a backend and how to use it.

The template did the part that takes the longest, the UI, and the agent with the Supabase MCP server did the plumbing. Your job was mostly to describe the data and read what came back.

Tips so it goes smoothly

  • Keep the MCP server scoped to one project with project_ref. Use a separate development project if you already have users in production.
  • Ask for SQL before it's applied on anything you care about. Reviewing a migration takes a minute.
  • Always ask for advisors and RLS tests after schema changes. Missing policies are the most common Supabase security issue and the advisors catch them.
  • Turn on email confirmation before launch. For development it's convenient to have it off; in production you want verified emails.
  • Regenerate types after every migration so the app and the database never drift.
  • Commit after each step. If the agent goes sideways, you throw away one step, not the afternoon.

FAQ

Do I need Claude Code to use Supabase with a React Native template?

No. Everything above can be done by hand with the Supabase dashboard and supabase-js. The MCP server just lets an agent do the repetitive parts (migrations, policies, type generation, testing) and check its own work. Cursor and other MCP-capable editors work the same way.

Does Supabase work with Expo Go?

Yes. supabase-js is plain JavaScript and stores the session with AsyncStorage, so there are no native modules to install. Apple and Google sign-in need extra setup for production builds, but email auth works in Expo Go out of the box.

Is it safe to put the Supabase key in my app?

The publishable key (or the legacy anon key) is meant to be public, as long as row level security is enabled and your policies are correct. The secret / service role key bypasses RLS and must never ship in an app.

Can I use Firebase or my own API instead?

Yes. Our templates don't assume a backend. The auth context and the screens are the same either way; you replace the inside of the context with calls to whatever you use. Supabase is just the fastest path we've found, because the MCP server lets the agent build and verify the backend without leaving the editor.

Which template should I start from?

If your app matches a category, start from the closest one: ecommerce, social, booking, dating, fitness, AI chat and more, so you get the screens and the agent gets working examples. For something new, the Front-End Starter gives you the component library, navigation and auth screens without a specific app on top. Want to try the workflow first? The free template is real code, and the vibe coding page explains how our templates are set up for AI agents.

All templates are TypeScript on Expo SDK 57 with NativeWind, and every one ships with a CLAUDE.md, which is exactly what makes connecting a backend like this a short job instead of a rewrite.