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.
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 |
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 componentThe 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. |
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.
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.
.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.
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:
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.
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.
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:
"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.