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.

LayerDirectoryWhat lives thereMay import
4. App shellapps/native/app/, apps/native/components/, apps/native/lib/Routes, providers, theme, i18n, React Query, layout chromeAnything below
3. Domain featuresapps/native/features/{accounts,auth,team-accounts,billing,device-info}/Screens' building blocks: components and data hooks for one product domainmakerkit, core, sibling domains
2. Shared UIapps/native/features/makerkit/components/Presentational components and thin core-backed widgetscore only
1. Infra kernelapps/native/features/core/Supabase client, API client, dev-host rewriting, error normalisation, configNothing 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 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 corepicture-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:

ExceptionImportWhy it is sanctioned
useAuth~/lib/auth/auth-contextThe 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-contextThe 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/dynamicResolving a runtime-built message key requires the generated catalogue, which lives with the i18n provider. See docs/native/i18n.mdoc.

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.

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:

ConcernLives inScreenReason
Challengefeatures/auth/hooks/mutations/use-verify-mfa-challenge.tsapp/(auth)/mfa-challenge.tsxA gate on the way in. It runs before the app shell is usable, alongside sign-in.
Enrollmentfeatures/accounts/hooks/mutations/{use-enroll-mfa-factor,use-verify-mfa-enrollment,use-unenroll-mfa-factor}.tsapp/(home)/(user)/(tabs)/settings/mfa-enroll.tsxAn 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 addingPut it inThen
A screenapps/native/app/… following the existing route groupsSee docs/native/routing-navigation.mdoc for groups, guards and modals
A reusable button or input variantpackages/mobile-ui/src/makerkit/, exported from its package.jsonSee docs/native/styling-theming.mdoc — never edit src/components/ui/
A component used by one domainapps/native/features/<domain>/components/Import @kit/mobile-ui/* primitives, not raw RN views
A component used by several domains, with no domain knowledgeapps/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 featureapps/native/features/<name>/ with components/ and hooks/Mirror the web name if one exists in packages/features/
A hook that reads /api/v1apps/native/features/<domain>/hooks/queries/use-*.tsSee docs/native/data-fetching.mdoc for api() and query keys
A hook that writesapps/native/features/<domain>/hooks/mutations/use-*.tsInvalidate with the shared *Keys helper from @kit/*/shared
A one-off hook with no query or mutationapps/native/features/<domain>/hooks/ roote.g. use-resend-cooldown.ts, use-role-label.ts
A React context or providerapps/native/lib/<concern>/ and mount it in app/_layout.tsxFeatures 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 schemaapps/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 toopackages/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.