The Expo Supabase SaaS Kit is a Makerkit starter kit that ships a React Native app for iOS and Android next to the Next.js web app. Both apps use the same Supabase project, the same RLS policies and the same @kit/* packages. The Senvio team was the main contributor to its development.
Click to expandThe screenshots in this post show the kit unmodified, running on a simulator against a local Supabase stack.
TL;DR
- What it is: a Makerkit kit containing the Next.js web app and an Expo (React Native) app in
apps/native, connected to the same Supabase project. - How to get it: clone the kit for a new project, or run
npx @makerkit/cli@latest plugins add expoto add the native app to an existing Makerkit 4.xnext-supabaseproject. - What works in the app: email and password sign-in, Google OAuth, Sign in with Apple, TOTP multi-factor authentication, personal accounts, team accounts, members, invitations and role-based permissions.
- What stays on web: plan selection and checkout. Selling subscriptions in the app would require in-app purchase, with a 15% to 30% platform commission and anti-steering rules.
- Stack: Expo SDK 57, React Native 0.86, React 19.2.3, expo-router, React Query, Uniwind (Tailwind v4 compiled for React Native), Supabase.
- Release status: this is the initial release. Expanded documentation, bug fixes and the planned features will ship in frequent updates.
How do you add a React Native app to a Makerkit SaaS?
You either start a new project from the Expo kit, or install the native app into an existing Makerkit Next.js Supabase project with the CLI plugin. The kit is a variant of the Next.js Supabase kit, not a separate product.
| Start from this kit | Add as a plugin | |
|---|---|---|
| You have | Nothing yet | A Makerkit Next.js + Supabase project |
| You get | apps/web and apps/native together, already wired | The same apps/native, copied into your repo and wired to it |
| How | git clone | npx @makerkit/cli@latest plugins add expo |
| Your existing work | Not applicable | Untouched, the plugin only adds files and edits config |
| Kit variants | This kit only | next-supabase only |
If you already have a product in production, use the plugin. It targets the v4 line and the next-supabase variant. The CLI refuses to install it on the Drizzle, Prisma, React Router or TanStack variants, because the plugin declares no configuration for them. Commit your working tree before running it: the install edits many files, and reviewing the result as a single diff is easier.
What the React Native app supports today
Authentication, personal accounts, team accounts and member management all work in the native app. The feature coverage page lists every feature with its status and feature flag. In summary:
- Auth: email and password sign-in, sign-up, password reset, Google OAuth, Sign in with Apple (iOS, off by default), a TOTP challenge that submits on the sixth digit, and team invitation acceptance.
- Personal account: display name, avatar upload to the
account_imagebucket, password change, MFA enrollment and unenrollment, and account deletion confirmed by OTP. - Team accounts: create a team, rename it, change the slug, upload a logo, and leave a team. RLS prevents the primary owner from leaving.
- Members and roles: the members list, invitations (invite, list, change role, revoke, renew), member role changes, and removal.
- Billing: a read-only subscription status badge in team settings. Removing a member still decrements the per-seat count, because the shared service in
packages/featureshandles seat updates for both apps.
Team features are often left out of mobile versions of SaaS apps, so here is what they look like. Creating a team opens a form sheet over the current screen:
Click to expandThere is no slug field by default. The server derives the slug from the team name, and the field only appears when the name uses non-Latin characters or the derived slug is already taken.
The Members tab has the same management features as the web app:
Click to expandThe screenshot shows two parts of the authorization model. Custom-Role is a row added to the roles table, and the app displays it without a code change, because roles are loaded at runtime rather than compiled into the binary as an enum. The row menus are hidden on your own row, on the primary owner's row, and on members whose role is at or above yours. The role picker is filtered by hierarchy level on the server, so the app never receives a role the current user is not allowed to assign.
All of this is enforced by the same RLS policies as the web app, so there is one authorization model to maintain. For background on how those policies are written, see our Supabase RLS best practices.
Three architecture decisions to know before reading the code
The native app is not a port of the web app's code. Three parts work differently on purpose.
Data goes through a versioned REST API, not the Supabase client
Screens that show server data call a React Query hook, which calls api(), which calls a route handler under apps/web/app/api/v1/* with the Supabase access token as a bearer token. The app calls the Supabase JS SDK directly in three cases only: auth (sign-in and session refresh), MFA enrollment and challenge, and storage uploads.
The reason is code sharing. Your product's rules live in services under packages/features, and a route handler can call those services. A supabase.from(...) call in a screen cannot, so the rule would be reimplemented in the app and the two copies would drift apart. Uploads keep using the SDK because sending an image through a JSON endpoint would mean base64-encoding it in the request body.
The session is stored in the keychain, in two parts
The web app keeps the session in an httpOnly cookie. A native app has no cookie store, so apps/native/features/core/supabase.ts gives Supabase a custom storage adapter called LargeSecureStore.
The adapter splits the session because iOS Keychain items are practically limited to about 2 KB, and a Supabase session (a JWT plus a refresh token) is larger than that:
| Half | Where it lives | What it is |
|---|---|---|
| Encryption key | expo-secure-store (Keychain / Keystore) | A fresh AES-256 key, generated per write |
| Encrypted blob | @react-native-async-storage/async-storage | The session, AES-CTR encrypted with that key |
The two halves can get out of sync after an OS keychain reset, a restore from backup, or an interrupted write. getItem treats any read failure as that case and clears both halves instead of throwing, so the user is sent to the sign-in screen instead of being left with a broken session. The authentication docs cover the full flow.
Email flows use six-digit codes instead of links
Sign-up confirmation, password reset and invitation acceptance all use a six-digit code sent by email. The browser that a mail client opens does not share cookies with the app, so link-based flows would need deep links, universal links and an HTTPS domain before they worked. Typed codes need none of that and work on a simulator from the first run.
Click to expandSubmitting this form replaces it with the code step on the same route instead of pushing a new screen. The hardware back button and the back swipe therefore return to the filled-in form rather than to sign-in.
Because of this design, code length, code expiry and the verification rate limit are security settings shared by web and native. Configuration and requirements lists the defaults. The web kit's own OTP support, used to confirm sensitive actions, is described in Using OTP Verification in Makerkit.
Styling with Tailwind in React Native (Uniwind)
The app is styled with Uniwind, which compiles Tailwind v4 for React Native through a Metro transformer. You write className on React Native components, and the theme tokens live in apps/native/global.css. There is no tailwind.config.js in apps/native or packages/mobile-ui, so rebranding the app means editing one file.
Light and dark mode use the same token set, and the user can choose light, dark or the system setting:
Click to expand
Click to expandThe header, tab bar and status bar also change theme. These are not styled with class names: React Navigation and expo-status-bar render them and take color props. The kit passes the theme tokens to those props, so editing global.css updates them along with the rest of the UI.
The most common styling mistake: React Native has no cascade and no CSS box model, so only utilities that Uniwind can map to a React Native style property have any effect. A class with no React Native equivalent is dropped without a build warning. This usually happens when porting a web component by hand. Styling and theming documents the mapping and where to add your own components so that kit updates do not overwrite them.
Why billing stays on the web app
Plan selection, checkout and the customer portal are web-only, for commercial reasons. Selling digital subscriptions inside a mobile app requires the platform's in-app purchase system, which takes a 15% to 30% commission and comes with anti-steering rules that restrict how you mention other payment options. In-app purchase also shares no code with the kit's Stripe or Lemon Squeezy integration: it has its own purchase flow, receipt model and reconciliation process.
Seat counts still update when members are added or removed in the app, because the shared service handles them. See per-seat Stripe subscriptions for how seat billing works in the web kit.
What is not in the app yet
These gaps are planned work for the coming releases, and the kit's documentation lists each one:
- Notifications (in-app list, bell, unread badge, realtime delivery) are planned.
supabase.channel()works in React Native without changes, so the web implementation can be ported. - Passkey management is planned. Passkey registration needs a relying-party domain, an Apple Team ID and an Android release SHA-256, so it cannot be tested against
localhost. - Team deletion, ownership transfer and email changes show a "manage on web" notice today.
Do not enable the global GoTrue captcha setting in Supabase. The native app sends no Turnstile token, and that setting applies to sign-in, OTP and password reset for every client, so it breaks native authentication completely.
Installing the Expo app into an existing project
npx @makerkit/cli@latest plugins add expoThe CLI detects your kit variant, then runs a codemod that you can also call directly with npx codemod @makerkit/next-supabase-expo. The installation guide lists every file it touches. It copies apps/native, packages/mobile-ui, the /api/v1 routes and the docs, then applies AST transforms to files you already have:
- the workspace catalog gets the Expo and React Native versions;
package.jsongets ten*:nativescripts;supabase/config.tomlallows themkkit://OAuth callback;- oxlint gets a rule that bans the web app's password schemas inside
apps/native.
Every step is idempotent. The file download is skipped when apps/native already exists, and an interrupted copy is rolled back instead of leaving a partial install. After copying, the codemod runs pnpm install and regenerates the message catalog from your web strings (otherwise the app would show our product name and copy), then runs typecheck and formatting.
Then reset Supabase so it picks up the new redirect URL, and start the app:
pnpm run supabase:web:resetpnpm run start:nativeThe app does not run in Expo Go. It depends on native modules that ship config plugins (expo-secure-store, expo-image-picker, expo-apple-authentication, expo-dev-client), so every device and simulator runs a development build you compile yourself. expo prebuild generates ios/ and android/ from app.json, and neither directory is committed. Building and shipping covers the App Store and Play Store requirements; read it before committing to a release date.
Who should use the Expo Supabase SaaS Kit?
Use it if:
- You are on the Makerkit Next.js Supabase kit and need an iOS and Android app without a second backend.
- You are starting a SaaS product where mobile is part of the initial plan.
- Your mobile app covers accounts, teams and your own product data, and purchases happen on the web.
Skip it if:
- You are on the Drizzle, Prisma, React Router or TanStack variants. The plugin has no configuration for them yet.
- Your app must sell subscriptions inside the app. You would need to build the in-app purchase flow yourself.
- You need in-app notifications or passkey sign-in at launch. Both are planned but not available yet.
Our recommendation: if you are on the Next.js Supabase kit at v4, install the plugin on a branch and run it against your local Supabase stack. It is one idempotent command, and within an hour you will know how much work your own features need to reach mobile.
Frequently Asked Questions
What is the Makerkit Expo Supabase SaaS Kit?
Can I add the native app to my existing Makerkit project?
Does it work with the Drizzle, Prisma, React Router or TanStack kits?
Why is billing not available in the app?
Can I run the app in Expo Go?
Why do sign-up and password reset use codes instead of email links?
Which versions does the kit target?
Credits
The Expo kit was built with the Senvio team as the main contributor. The secure session storage, the code-based email flows and the /api/v1 API layer described above came out of that work.
Next steps
See the React Native SaaS Starter Kit page for features and pricing. Read the introduction to the Expo app for the full architecture, then feature coverage for the complete list of what ships. If you are adding the app to an existing project, the Next.js Supabase kit docs describe the v4 baseline the plugin expects.