Expo Architecture — Project Structure & Patterns
Conventions for building Expo Router apps targeting iOS with TypeScript.
Project Structure
code
├── app/ # Expo Router file-based routing │ ├── _layout.tsx # Root layout (providers, fonts, splash) │ ├── index.tsx # Entry redirect or landing │ ├── +not-found.tsx # 404 fallback │ ├── (auth)/ # Auth group (unauthenticated) │ │ ├── _layout.tsx │ │ ├── login.tsx │ │ ├── register.tsx │ │ └── forgot-password.tsx │ ├── (tabs)/ # Main tab navigator (authenticated) │ │ ├── _layout.tsx # Tab bar configuration │ │ ├── index.tsx # Home tab │ │ ├── explore.tsx # Second tab │ │ └── profile.tsx # Profile tab │ ├── (modals)/ # Modal routes │ │ ├── _layout.tsx # Modal presentation config │ │ └── settings.tsx │ └── [id]/ # Dynamic routes │ └── detail.tsx ├── src/ │ ├── components/ # Reusable UI components │ │ ├── ui/ # Primitive/atomic components │ │ │ ├── Button.tsx │ │ │ ├── Text.tsx │ │ │ ├── Input.tsx │ │ │ └── Card.tsx │ │ ├── forms/ # Form-specific components │ │ ├── layout/ # Layout wrappers │ │ │ ├── SafeArea.tsx │ │ │ ├── ScreenWrapper.tsx │ │ │ └── KeyboardAvoid.tsx │ │ └── [feature]/ # Feature-specific components │ ├── hooks/ # Custom React hooks │ │ ├── useAuth.ts │ │ ├── useSupabase.ts │ │ └── use[Feature].ts │ ├── lib/ # Core utilities and setup │ │ ├── supabase.ts # Supabase client initialization │ │ ├── constants.ts # App-wide constants │ │ ├── storage.ts # AsyncStorage helpers │ │ └── utils.ts # Pure utility functions │ ├── services/ # External API integrations │ │ ├── api/ # Supabase query functions │ │ │ ├── auth.ts │ │ │ ├── profiles.ts │ │ │ └── [feature].ts │ │ └── [third-party].ts # Third-party SDK wrappers │ ├── stores/ # State management (Zustand) │ │ ├── useAuthStore.ts │ │ └── use[Feature]Store.ts │ ├── types/ # TypeScript type definitions │ │ ├── database.ts # Supabase generated types │ │ ├── navigation.ts # Route params │ │ └── [feature].ts │ └── theme/ # Design tokens and theming │ ├── colors.ts │ ├── typography.ts │ ├── spacing.ts │ └── index.ts ├── assets/ # Static assets │ ├── images/ │ ├── fonts/ │ └── icons/ ├── docs/ # Documentation │ ├── PRD.md │ └── tasks/ ├── supabase/ # Supabase local dev │ ├── migrations/ │ ├── functions/ │ └── seed.sql ├── app.config.ts # Expo config (dynamic) ├── CLAUDE.md # Claude Code project conventions ├── tsconfig.json └── package.json
File Naming Conventions
| Type | Convention | Example |
|---|---|---|
Screens (in app/) | kebab-case | forgot-password.tsx |
| Components | PascalCase | ProfileCard.tsx |
| Hooks | camelCase with use prefix | useAuth.ts |
| Utils/Services | camelCase | formatDate.ts |
| Types | camelCase | database.ts |
| Stores | camelCase with use prefix | useAuthStore.ts |
| Constants | camelCase file, UPPER_SNAKE values | constants.ts → MAX_RETRIES |
Navigation Patterns
Root Layout Pattern
typescript
// app/_layout.tsx
export default function RootLayout() {
const { session, isLoading } = useAuth();
if (isLoading) return <SplashScreen />;
return (
<Stack screenOptions={{ headerShown: false }}>
{session ? (
<Stack.Screen name="(tabs)" />
) : (
<Stack.Screen name="(auth)" />
)}
<Stack.Screen
name="(modals)"
options={{ presentation: 'modal' }}
/>
</Stack>
);
}
Protected Route Groups
- •
(auth)— Screens visible when NOT authenticated - •
(tabs)— Main app screens, require authentication - •
(modals)— Modal overlays accessible from anywhere - •
(onboarding)— First-run experience
Navigation Rules
- •Use Expo Router's
<Link>for declarative navigation - •Use
router.push()for imperative navigation - •Use
router.replace()for auth redirects (no back button) - •Pass params via URL:
router.push(/item/${id}) - •Type your route params in
src/types/navigation.ts
Component Architecture
Component Levels
- •UI Primitives (
src/components/ui/) — Styled wrappers around RN primitives. No business logic. Accept style props. - •Composites (
src/components/forms/,layout/) — Combine UI primitives. Minimal logic. Reusable across features. - •Feature Components (
src/components/[feature]/) — Feature-specific. Can use hooks and stores. NOT reusable across features. - •Screens (
app/) — Full screen. Compose feature components. Handle navigation and route params.
Component Rules
- •Every component gets its own file (no multi-component files)
- •Props interface exported alongside component
- •Default exports for screens, named exports for components
- •Co-locate component-specific hooks in the same directory
- •No inline styles longer than 3 properties — extract to StyleSheet
State Management
State Location Decision Tree
code
Is it server state (from Supabase)? → Use React Query / TanStack Query with Supabase Is it shared across multiple screens? → Use Zustand store Is it form state? → Use React Hook Form or local useState Is it only used in one component tree? → Use useState / useReducer Is it derived from other state? → Use useMemo, don't store it
Zustand Store Pattern
typescript
// src/stores/useAuthStore.ts
import { create } from 'zustand';
interface AuthState {
session: Session | null;
setSession: (session: Session | null) => void;
signOut: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set) => ({
session: null,
setSession: (session) => set({ session }),
signOut: async () => {
await supabase.auth.signOut();
set({ session: null });
},
}));
Data Flow
code
Supabase → src/services/api/[feature].ts → Hook or Query → Screen → Component
↑
Zustand (if shared state)
Rules
- •Screens never call
supabasedirectly — always throughsrc/services/api/ - •Components never call API functions — screens pass data as props
- •Mutations happen through service functions, not raw Supabase calls
- •Real-time subscriptions set up in hooks, not in components
Performance Patterns
- •Use
React.memo()for list item components - •Use
FlashListinstead ofFlatListfor long lists - •Lazy-load heavy screens with
React.lazy+Suspense - •Preload images with
expo-imagecache policies - •Avoid anonymous functions in
renderItem— extract named functions - •Use
useCallbackfor event handlers passed to child components
Error Handling
code
Screen level: ErrorBoundary wraps each screen API level: try/catch in service functions, return typed errors Component level: Conditional rendering for error/loading/empty states Global level: Root ErrorBoundary in _layout.tsx
Every screen should handle 4 states:
- •Loading — Skeleton or spinner
- •Error — Retry button + error message
- •Empty — Helpful empty state with CTA
- •Success — The actual content
New Screen Checklist
When creating a new screen:
- • Create route file in
app/with correct group - • Add type-safe route params if needed
- • Wrap content in
ScreenWrapperorSafeArea - • Handle loading, error, empty, success states
- • Add to navigation if not auto-discovered
- • Verify it renders on iOS simulator