# Expo app project structure and import rules | Expo Supabase SaaS Kit

> Learn where your own code belongs in the Expo app — the four layers, the one-way import rule that keeps them apart, and a decision table for placing a new screen, component, hook or provider.

*Canonical: https://makerkit.dev/docs/react-native-supabase/project-structure*

---

The Expo app in `apps/native` is organised in four layers with a single one-way import rule between them. This page tells you which layer your new code belongs to and why the boundaries are drawn where they are. Read it before you add your first screen — every other page in this section assumes it.

## The four layers

Dependencies point one way: down. Each layer may import from the layers beneath it and from `@kit/*` workspace packages. Nothing imports upward.

| Layer | Directory | What lives there | May import |
| --- | --- | --- | --- |
| 4. App shell | `apps/native/app/`, `apps/native/components/`, `apps/native/lib/` | Routes, providers, theme, i18n, React Query, layout chrome | Anything below |
| 3. Domain features | `apps/native/features/{accounts,auth,team-accounts,billing,device-info}/` | Screens' building blocks: components and data hooks for one product domain | `makerkit`, `core`, sibling domains |
| 2. Shared UI | `apps/native/features/makerkit/components/` | Presentational components and thin `core`-backed widgets | `core` only |
| 1. Infra kernel | `apps/native/features/core/` | Supabase client, API client, dev-host rewriting, error normalisation, config | Nothing in the app |

```text
apps/native/
├── app/                      # expo-router routes — the only place a screen lives
├── components/               # layout chrome: home-header.tsx, tab-stack.tsx
├── lib/
│   ├── auth/auth-context.tsx         # ambient session state
│   ├── workspace/                    # active workspace + persistence + Href helpers
│   ├── theme/                        # preference context, navigation theme, colours
│   ├── i18n/                         # provider, generated catalogue, tDynamic
│   └── react-query-provider.tsx
└── features/
    ├── core/                 # supabase.ts, api-client.ts, dev-host.ts, errors.ts, config/
    ├── makerkit/components/  # error-state, loading-state, picture-controls, …
    ├── accounts/             # components/ + hooks/{queries,mutations}/
    ├── auth/                 # components/ + hooks/ + config/ + lib/ + schemas/
    ├── team-accounts/        # components/ + hooks/{queries,mutations}/
    ├── billing/              # components/ (read-only status — billing stays on web)
    └── device-info/          # flat, single native-only diagnostic component
```

The feature directories are not forced into a uniform shape. `accounts` and `team-accounts` have the full `components/` plus `hooks/queries/` plus `hooks/mutations/` split; `auth` adds `config/`, `lib/` and `schemas/` and has only mutations, because a sign-in flow reads almost nothing; `billing` is one badge component; `device-info` is a single file. Add the subdirectory when you have something to put in it, not before.

### Why `core` is separate from `makerkit`

`features/core/` holds no React composition. It is the bottom of the graph, so it can be imported from anywhere without creating a cycle: `supabase.ts` and `api-client.ts` are module singletons, `dev-host.ts` is pure string rewriting, `errors.ts` normalises unknown throwables, and `config/` reads `EXPO_PUBLIC_*` at module scope. Every configuration surface is documented in `docs/native/config-requirements.mdoc`.

`features/makerkit/components/` sits one level up: shared components that any domain feature may reuse. Most are presentational (`error-state`, `loading-state`, `settings-section`, `search-input`), but a few are thin widgets backed by `core` — `picture-controls.tsx` does Supabase Storage upload and delete directly. That is allowed. What is not allowed is a file in `makerkit/` importing a domain feature.

## The one-way import rule

Files under `apps/native/features/` import from `@kit/*` or `~/features/*` only. There are three exceptions, all pointing at ambient app-level state:

| Exception | Import | Why it is sanctioned |
| --- | --- | --- |
| `useAuth` | `~/lib/auth/auth-context` | The session is ambient. Threading `session` through every hook signature would be noise, and there is exactly one session per running app. |
| `useActiveWorkspace` | `~/lib/workspace/workspace-context` | The selected workspace is resolved from the route and persisted at the app layer. A feature hook needs to read it; it must never own it. |
| `tDynamic` | `~/lib/i18n/dynamic` | Resolving a runtime-built message key requires the generated catalogue, which lives with the i18n provider. See `docs/native/i18n.mdoc`. |

{% alert type="warning" title="The exceptions are not a general escape hatch" %}
`apps/native/AGENTS.md` documents two exceptions; the code has three — `tDynamic` is used by `features/makerkit/components/web-only-notice.tsx`, `features/team-accounts/hooks/use-role-label.ts` and `features/billing/components/subscription-status-badge.tsx`. Treat the list above as closed. Any *other* `~/lib/*` or `~/app/*` import from inside `features/` means the code is at the wrong layer.
{% /alert %}

### The smell the rule prevents

The failure mode is a shared layer reaching down into a domain feature. Once `makerkit/` imports from `team-accounts/`, it stops being shared — you cannot reuse it without dragging a domain in, and the module graph gains a cycle the first time `team-accounts/` imports back.

`apps/native/components/home-header.tsx` is the concrete case. It renders the workspace avatar, a workspace switcher trigger and a pending-invitation badge, so it imports `useUserWorkspace` from `accounts`, `useMyInvitations` from `team-accounts`, `AppLogo` and `WorkspaceAvatar` from `makerkit`, and `useActiveWorkspace` from `lib`. It looks like a shared component, and it is shared — across tabs. But it is *layout glue over three domains*, so it lives in `apps/native/components/`, the app shell, not in `features/makerkit/`. Same for `tab-stack.tsx`.

The test is not "is this reused?" but "does this know about a domain?". If it does, it cannot live below the domain layer.

Sibling domain features may import each other — `features/auth/components/oauth-providers.tsx` reads an accounts hook, `features/accounts/components/delete-account-flow.tsx` reads an auth one. That is peer-level and permitted; only the downward direction is constrained.

{% alert type="info" title="The rule is a convention, not a lint error" %}
`.oxlintrc.json` has an `apps/native/**` override, but it enforces one thing only: `PasswordSignUpSchema`, `PasswordResetSchema`, `RefinedPasswordSchema` and `webPasswordRequirements` may not be imported from `@kit/auth/shared`, because those resolve web's policy from `NEXT_PUBLIC_*` variables that are always undefined in a Metro bundle. Use `~/features/auth/schemas/password.schema` instead. The layer rule itself is enforced by review.
{% /alert %}

## No barrel files

There is not a single barrel `index.ts` in `apps/native` or `packages/mobile-ui`. Every import is a deep path to the exact module:

```ts
import { useTeamMembers } from '~/features/team-accounts/hooks/queries/use-team-members';
import { supabase } from '~/features/core/supabase';
```

Two reasons. First, a barrel makes the bundler's job worse here: Expo SDK 57 ships `inlineRequires: false` in its default Metro transform options, so `require` calls are hoisted and evaluated eagerly. Importing one hook through a barrel would evaluate every module that barrel re-exports — including the Supabase client and any module with side effects at import time. Second, flat deep paths make cycles structurally hard to write: a cycle needs two modules that name each other, and a barrel is the usual way that happens by accident.

The only `index.*` files in the app are expo-router route files (`app/(home)/index.tsx` and friends, where the name is a route segment) and `features/auth/components/provider-logos/index.tsx`, which is a real component module whose directory carries its name — not a re-export.

## Mirroring `apps/web`

The feature names deliberately match web's `packages/features/{accounts,auth,team-accounts}`, so a port is a lookup rather than a search. Platform-neutral code is genuinely shared, not copied: the native app depends on `@kit/accounts`, `@kit/auth` and `@kit/team-accounts` and imports their `/shared` subpath for zod schemas, query keys and types.

```ts
import { AccountDetailsSchema } from '@kit/accounts/shared';
import { teamAccountKeys } from '@kit/team-accounts/shared';
```

Those `/shared` paths are lint-guarded repo-wide against `next`, `next-intl`, `react-dom`, `@kit/ui` and browser globals, which is what makes them safe to bundle with Metro.

### The one asymmetry: MFA

MFA is split across two features, and the split is intentional:

| Concern | Lives in | Screen | Reason |
| --- | --- | --- | --- |
| Challenge | `features/auth/hooks/mutations/use-verify-mfa-challenge.ts` | `app/(auth)/mfa-challenge.tsx` | A gate on the way in. It runs before the app shell is usable, alongside sign-in. |
| Enrollment | `features/accounts/hooks/mutations/{use-enroll-mfa-factor,use-verify-mfa-enrollment,use-unenroll-mfa-factor}.ts` | `app/(home)/(user)/(tabs)/settings/mfa-enroll.tsx` | An account setting. You are already signed in, editing your own account. |

The factor *list* (`features/accounts/hooks/queries/use-mfa-factors.ts`) sits with enrollment, because listing factors is account state — the challenge screen imports it across the boundary, which is the peer-level import the rule permits. Auth behaviour end to end is covered in `docs/native/authentication.mdoc`.

## Where `@kit/mobile-ui` ends and the app begins

`packages/mobile-ui` holds pure cross-platform UI primitives and nothing else: React Native Reusables components with a shadcn-style API mirroring `@kit/ui`, imported as flat per-component subpaths.

```ts
import { Button } from '@kit/mobile-ui/button';
import { Text } from '@kit/mobile-ui/text';
```

A component belongs there only if it knows nothing about Supabase, your routes, or your domain. Anything that does belongs in `apps/native/features/`.

Two rules matter when you extend it. `packages/mobile-ui/src/components/ui/` is generated by the RNR CLI — `add --overwrite` silently discards hand edits on the next resync, so project-specific components go in the sibling `packages/mobile-ui/src/makerkit/` (where `form`, `sonner` and `trans` already live) and get wired into the package's `exports` map. And the package carries no `tailwind.config.js`: Uniwind is CSS-first and the theme lives in `apps/native/global.css`. `docs/native/styling-theming.mdoc` owns both in detail, including how to add a component.

## The `~/` path alias

`~/` resolves to `apps/native/`, declared in `apps/native/tsconfig.json`:

```json
"paths": {
  "~/*": ["./*"]
}
```

TypeScript reads it from there. At bundle time Expo's Metro config honours `tsconfig` paths by default (`experiments.tsconfigPaths`), so no Babel module-resolver plugin is involved and `apps/native/metro.config.js` needs no alias entry. Use `~/` for everything inside the app; relative paths only for a file's immediate neighbours.

## Where does my new code go?

| You are adding | Put it in | Then |
| --- | --- | --- |
| A screen | `apps/native/app/…` following the existing route groups | See `docs/native/routing-navigation.mdoc` for groups, guards and modals |
| A reusable button or input variant | `packages/mobile-ui/src/makerkit/`, exported from its `package.json` | See `docs/native/styling-theming.mdoc` — never edit `src/components/ui/` |
| A component used by one domain | `apps/native/features/<domain>/components/` | Import `@kit/mobile-ui/*` primitives, not raw RN views |
| A component used by several domains, with no domain knowledge | `apps/native/features/makerkit/components/` | If it needs a domain hook, it belongs in `apps/native/components/` instead |
| Layout chrome spanning domains (headers, tab shells) | `apps/native/components/` | It may import anything — that is why it lives at the top |
| A whole new domain feature | `apps/native/features/<name>/` with `components/` and `hooks/` | Mirror the web name if one exists in `packages/features/` |
| A hook that reads `/api/v1` | `apps/native/features/<domain>/hooks/queries/use-*.ts` | See `docs/native/data-fetching.mdoc` for `api()` and query keys |
| A hook that writes | `apps/native/features/<domain>/hooks/mutations/use-*.ts` | Invalidate with the shared `*Keys` helper from `@kit/*/shared` |
| A one-off hook with no query or mutation | `apps/native/features/<domain>/hooks/` root | e.g. `use-resend-cooldown.ts`, `use-role-label.ts` |
| A React context or provider | `apps/native/lib/<concern>/` and mount it in `app/_layout.tsx` | Features read it through a hook; they never own provider state |
| App-wide config read from `EXPO_PUBLIC_*` | `apps/native/features/core/config/` | See `docs/native/config-requirements.mdoc` before adding a variable |
| Auth-only config or a zod schema | `apps/native/features/auth/config/` or `…/schemas/` | Keeps the password policy out of `core`, which must stay React-free |
| A shared zod schema or query key used by web too | `packages/features/<name>/src/shared/` | Lint-guarded against browser and Next.js imports |

If a piece of code fits two rows, put it in the lower layer — moving code up later is a rename, moving it down means untangling an import you should not have written.
