The Expo React Native app | Expo Supabase SaaS Kit
Understand what the Expo app in apps/native is, how it shares a database, an API and packages with the web app, and which page to read next.
The kit ships a second application alongside the Next.js one: an Expo (React Native) app in apps/native, building for iOS and Android from the same monorepo, against the same Supabase project as apps/web. This page explains what that app is, what it is not, and where the rest of this section takes you. Read it before you open the code — the app makes a few deliberate architectural choices that look surprising if you approach it as "the web app, in React Native".
What apps/native is
apps/native is an Expo app that covers a subset of the web kit: the surfaces a signed-in customer uses on a phone. Authentication, personal account settings, team accounts, members and invitations, and the app shell around them.
It is not a port of apps/web. Nothing in the kit tries to keep the two screen-for-screen identical, and several web surfaces are intentionally absent — see What is deliberately not there. Nor is it a WebView wrapper: every screen is a real React Native screen.
The two apps are siblings, not layers. apps/native does not import from apps/web, and apps/web does not import from apps/native. What they share is the database, a set of @kit/* packages, and an HTTP contract.
How it relates to apps/web
| Concern | Shared with apps/web? | How |
|---|---|---|
| Supabase project and database | Yes | Both point at the same project. Locally that is the stack started by pnpm run supabase:web:start; apps/native/.env.development sets EXPO_PUBLIC_SUPABASE_URL to the same 127.0.0.1:54321. |
| Database access | Almost entirely indirect | Native reads and writes through web's versioned /api/v1/* route handlers, so authorization and business rules live in one place. The one exception is usePersonalAccount, which calls fetchPersonalAccount() from @kit/accounts/shared — a direct RLS-protected table read. See docs/native/data-fetching.mdoc. |
| Authorization | Yes | RLS in the database, plus the same services the web Server Actions call, invoked from the /api/v1 handlers. |
| Authentication | Yes, same GoTrue | Native calls @supabase/supabase-js directly for sign-in, MFA, and session refresh. The session lives in the Keychain / Keystore, not a cookie. |
| Storage | Yes | Avatar and team-logo uploads go straight to Supabase Storage from the device. |
| Validation schemas | Yes | Imported from the @kit/*/shared subpaths, so a rule tightened on web tightens on native. |
| UI components | No | Web uses @kit/ui (Radix + Tailwind for the DOM); native uses @kit/mobile-ui (React Native Reusables + Uniwind). |
| Feature flags | Mirrored, not shared | Each NEXT_PUBLIC_* flag has an EXPO_PUBLIC_* twin that you set separately. |
| Routing config | No | Web's navigation config files have no native counterpart; native declares its tabs in the route layouts. |
Why the database goes through /api/v1
Supabase's client SDK can query the database from React Native perfectly well. The kit deliberately does not do that.
A mobile binary is distributed through the app stores and updated on the user's schedule — you cannot assume every installed copy is running this week's code. If business rules lived in the app (which roles may be assigned, when a seat count changes, whether team creation is allowed), an old binary would keep enforcing an old version of them, and every rule would exist twice. Routing data through apps/web/app/api/v1/* keeps one implementation of each rule on a server you can redeploy, and gives the mobile client a versioned contract: fields get added within v1, and removals or renames go to a new version.
The wrapper that native calls those endpoints with is api() in apps/native/features/core/api-client.ts. It attaches the current Supabase JWT, prefixes /api/v1, applies a timeout, and normalizes failures into a typed ApiError carrying the handler's status and machine-readable code:
import type { UserWorkspaceShape } from '@kit/accounts/shared';import { api } from '~/features/core/api-client';const workspace = await api<UserWorkspaceShape>('/me/workspace');On the server side, enhanceRouteHandler from @kit/next/routes detects the Authorization: Bearer <jwt> header and builds an RLS-bound client for that user, so a handler is written once and serves both the cookie-bearing browser and the token-bearing app. The endpoint list and the shape of each response are documented in apps/web/app/api/v1/AGENTS.md; adding an endpoint and the React Query hook that consumes it is covered in docs/native/data-fetching.mdoc.
Supabase's JS SDK is still used directly, but only for the three things an HTTP wrapper would make worse: auth (the session must live in the client to be refreshed), MFA (challenge and enrollment are GoTrue APIs), and storage (uploading an image through a JSON endpoint would mean base64 in a request body).
Tech stack
Versions come from pnpm-workspace.yaml — every native dependency is pinned there under the expo named catalog rather than in apps/native/package.json, so an SDK bump is one file.
| Concern | Choice | Version |
|---|---|---|
| Runtime | Expo SDK, React Native, React | 57.0.8 / 0.86.0 / 19.2.3 |
| Routing | expo-router, file-based, typed routes on | 57.0.8 |
| Styling | Uniwind — Tailwind v4, CSS-first, for React Native | 1.10.0 |
| Components | @kit/mobile-ui, React Native Reusables primitives | workspace |
| Backend SDK | @supabase/supabase-js with an encrypted expo-secure-store adapter | 2.110.8 |
| Server state | TanStack Query | 5.101.4 |
| Forms | react-hook-form + zod resolvers | 7.83.0 / 4.4.3 |
| Translations | use-intl, against a generated catalogue | 4.13.4 |
The React Compiler is enabled through babel-preset-expo in apps/native/babel.config.js.
The @kit/* packages apps/native depends on:
| Package | What native takes from it |
|---|---|
@kit/mobile-ui | Every UI primitive — text, button, input, form, sonner, icon, and the rest. |
@kit/auth/shared | Sign-in, sign-up and password schemas, shared with web's forms. |
@kit/accounts/shared | Personal-account schemas and query keys. |
@kit/team-accounts/shared | Team, member and invitation schemas and types. |
@kit/supabase | The generated Database type, so the Supabase client is typed. |
@kit/shared | Declared as a dependency; reached in practice through @kit/mobile-ui. |
Only the /shared subpaths are imported. Those entrypoints are free of React DOM and Next.js imports, which is what makes them safe to pull into a Metro bundle.
What ships in the app today
At a high level: password sign-in and sign-up, password reset, MFA challenge and TOTP enrollment, Google (and other) OAuth, personal account settings including avatar and account deletion, team creation and settings, the full members/invitations/roles surface, a workspace switcher across personal and team accounts, theming, and a device-info diagnostics screen.
Every email flow uses a typed 6-digit code rather than a link, because the browser your mail client opens shares no cookie jar with the app — so sign-up confirmation, password reset and invitation acceptance need no deep-link infrastructure at all. docs/native/authentication.mdoc covers the flows.
docs/native/covered-features.mdoc lists every feature with a status (Parity, Adapted, Planned, Web-only, Declined), the flag that gates it, and the store constraint attached to it. When this section and that page disagree about whether something is built, that page wins.
What is deliberately not there
| Surface | Why it stays on web |
|---|---|
| Billing checkout, plans, customer portal | Selling subscriptions inside a mobile app means platform in-app purchase — a commission and a separate purchase, receipt and reconciliation model that shares no code with the kit's Stripe or Lemon Squeezy integration. Team subscription status is still shown read-only, and seat counts still update from native member changes. |
| Admin and super-admin | An operator tool. Shipping it in a store-distributed binary widens the attack surface with no user benefit. |
| Marketing pages, blog, CMS content | Acquisition surfaces, aimed at people who have not installed the app. The marketing message namespace is never even inherited into the native catalogue. |
"Not there" is not the same as "unreachable": some settings rows render as a disabled row with a "manage on web" notice rather than disappearing, so the app never silently loses a capability. Which ones, and the Planned features that are simply not built yet, are in docs/native/covered-features.mdoc.
Where to go next
| If you want to | Read |
|---|---|
| Get the app — a fresh clone, or added to a project you already have | docs/native/installation.mdoc |
| Run the app on a simulator or device | docs/native/running-locally.mdoc |
| Understand the folder layers and the import rule | docs/native/project-structure.mdoc |
| Add a screen, or understand the route guards | docs/native/routing-navigation.mdoc |
| Work on sign-in, MFA, OAuth or session storage | docs/native/authentication.mdoc |
| Understand personal vs team accounts and the switcher | docs/native/workspaces.mdoc |
| Add an endpoint and a hook that calls it | docs/native/data-fetching.mdoc |
| Change theme tokens or add a component | docs/native/styling-theming.mdoc |
| Build a form | docs/native/forms.mdoc |
| Produce a build, or ship to the stores | docs/native/building-shipping.mdoc |
| Look up a setting, or what changing one forces elsewhere | docs/native/config-requirements.mdoc |
| Change a string, or add a locale | docs/native/i18n.mdoc |
| Check whether a feature is built | docs/native/covered-features.mdoc |
| Fix a build, cache or typecheck problem | docs/native/troubleshooting.mdoc |
If you already have this repository checked out, start at docs/native/running-locally.mdoc — the app reads its Supabase URL, API base URL and feature flags from apps/native/.env.development, and seeing it run against your local stack makes the rest of this section concrete. If you are adding the Expo app to a Makerkit project you already have, start at docs/native/installation.mdoc instead.