A React Native project starts as a folder of screens and stays pleasant for about a week. This lesson is the layout that survives a second developer, and the two or three rules that keep it from rotting.
Feature-first, not type-first
The default instinct is to group by what a file is: all the components together, all the hooks together, all the contexts together. It reads well in a tutorial and badly at scale, because one change then touches four folders and no folder tells you what the app does.
Group by what a file is for instead:
app/ expo-router — the folder structure IS the navigation graph
src/
api/ client.ts (the ONLY fetch) · config · endpoints/*.api.ts
components/ui/ the design system: Button, Card, Sheet, TextField, Screen
domain/ money.ts, ids.ts — pure, React-free
features/
auth/ state/ · screens/ · components/
cart/ state/ · components/
checkout/ payment/ · hooks/ · components/ · screens/
menu/ orders/ profile/ home/
providers/ AppProviders · ToastProvider
storage/ secureStorage · deviceStorage
theme/ tokens.ts + theme.ts
types/ the API contractA feature owns its state, its screens and its components. Opening features/cart/
shows you everything the cart is.
The rule that keeps it honest
Shared things move down, never sideways. When two features need the same
component, it does not go into whichever feature got there first — it moves into
components/ui. When two features need the same rule, it moves into
domain/. A feature importing from a sibling feature is the smell that says something
belongs one level lower.
One deliberate exception in the demo app: checkout imports the cart's state, because a checkout without a cart is meaningless. That is a real dependency, not a shortcut, and it points in one direction only.
Routes name screens, they do not implement them
Every file under app/ is one line. It is worth repeating here because it is what
makes the rest of the structure possible: screens live in features, routes point at them, and a
screen can be rendered in a test without a router.
Path aliases
"paths": {
"@/*": ["./src/*"]
},So a screen imports @/features/cart/state/CartProvider rather than counting
../ segments. Metro reads this straight from tsconfig.json, so there is no
second copy to keep in sync — and moving a file stops breaking imports in files that did not
change.
One place that talks to the network
export { apiClient } from './client';
export type { RequestOptions, HttpMethod } from './client';
export { ApiError, NetworkError, toUserMessage } from './apiError';
export { API_BASE_URL, STRIPE_PUBLISHABLE_KEY, isStripeConfigured } from './config';
export { catalogApi } from './endpoints/catalog.api';
export { cartApi } from './endpoints/cart.api';
export { orderApi } from './endpoints/order.api';
export { authApi } from './endpoints/auth.api';
export { profileApi } from './endpoints/profile.api';A barrel over one module per domain. Screens import catalogApi and get autocomplete
for everything the menu can do; no screen ever builds a URL or reaches for fetch.
The payoff is that "where does the token get attached?" and "what is the base URL?" have exactly
one answer each. Lesson 15 is about what lives inside client.ts.
A note on barrels
Barrel files are convenient and can hurt: importing one name pulls the whole module graph, which
costs startup time and can create cycles. Use them at boundaries you actually want to be public —
api/, components/ui/, theme/ — and import features directly by
path rather than adding a barrel per folder.
One shared type contract
The types the API returns live in types/, split one file per domain, and they mirror
the backend's DTOs exactly. If a shape drifts, TypeScript fails the build instead of the UI failing
at runtime.
Two decisions there worth copying. Types the app cannot use are not defined at all — the mobile app is customer-facing, so admin types are absent on purpose, because importing one would only invite somebody to build the screen. And identifiers are aliased:
export type UUID = string;It compiles to nothing and documents everything. Every id the API exposes is a UUID, never a
sequential integer — because sequential ids let anyone walk /api/orders/1,
/2, /3.
Keep the pure parts pure
domain/ holds the money arithmetic and the id generator. Nothing in it imports React
or React Native, and that is the whole point: it is the part of the app that can be tested without
rendering anything, and it happens to be the part most worth testing.
When you find business logic inside a component, this is where it wants to go.
Strictness worth turning on
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,noUncheckedIndexedAccess is the one that earns its keep. It types
products[0] as Product | undefined, which is the truth — an empty menu is
a real state the app has to render. It is mildly annoying for a day and then it stops you shipping
"from $Infinity" to a customer, which is exactly what Math.min() of an empty array
produces.
What is next
State: Context, Reducers and Custom Hooks — and the ordering problem nobody warns you about.