When an app grows, the first thing that breaks is not your code — it’s your structure. Random files, “shared” folders that become dumping grounds, vendor SDK imports inside UI, and unreadable screens that do everything.
This guide shows a simple, production-grade folder pattern for an Expo app that stays clean as it scales. It uses:
- Expo Router (
app/routes stay thin) - Feature-based architecture (
src/features/) - Strict service boundaries (
src/services/owns SDKs) - One Zustand store (
src/store/) - Persistence boundary (
src/persistence/) - Shared UI kit (
src/ui/) - Localization, docs, scripts, and CI
This is intentionally minimal, but structured enough for enterprise-grade work.
1) Full Folder Structure
.
├─ app/ # Expo Router routes ONLY (thin screens)
│ ├─ _layout.tsx # Root providers + bootstrap init
│ ├─ (tabs)/
│ │ ├─ _layout.tsx
│ │ ├─ home.tsx # imports src/features/home/screens/HomeScreen
│ │ ├─ insights.tsx
│ │ └─ settings.tsx
│ ├─ (modals)/ # keep folder only if you truly use modals
│ │ └─ example-modal.tsx # remove if unused
│ ├─ +not-found.tsx
│ └─ index.tsx
│
├─ src/
│ ├─ features/ # Feature-based domain code
│ │ ├─ home/
│ │ │ ├─ screens/
│ │ │ │ └─ HomeScreen.tsx
│ │ │ ├─ components/
│ │ │ │ ├─ HomeHeader.tsx
│ │ │ │ └─ HomeFeed.tsx
│ │ │ ├─ hooks/
│ │ │ │ └─ useHomeFeed.ts
│ │ │ ├─ types.ts
│ │ │ ├─ constants.ts
│ │ │ └─ index.ts
│ │ ├─ insights/
│ │ │ ├─ screens/
│ │ │ ├─ components/
│ │ │ ├─ hooks/
│ │ │ ├─ types.ts
│ │ │ ├─ constants.ts
│ │ │ └─ index.ts
│ │ ├─ settings/
│ │ │ ├─ screens/
│ │ │ ├─ components/
│ │ │ ├─ hooks/
│ │ │ ├─ types.ts
│ │ │ ├─ constants.ts
│ │ │ └─ index.ts
│ │ └─ ... other features
│ │
│ ├─ services/ # ONLY boundary to vendor SDKs
│ │ ├─ analytics/
│ │ │ ├─ analytics.types.ts
│ │ │ ├─ analytics.service.ts
│ │ │ ├─ analytics.firebase.ts
│ │ │ ├─ analytics.mock.ts
│ │ │ └─ index.ts
│ │ ├─ crash/
│ │ │ ├─ crash.service.ts
│ │ │ ├─ crash.firebase.ts
│ │ │ ├─ crash.mock.ts
│ │ │ └─ index.ts
│ │ ├─ ads/
│ │ │ ├─ ads.config.ts # env gating + unit id resolver (test vs prod)
│ │ │ ├─ ads.interstitial.ts
│ │ │ ├─ ads.rewarded.ts
│ │ │ ├─ native/
│ │ │ │ ├─ nativeAd.cache.ts
│ │ │ │ └─ NativeAdCard.tsx
│ │ │ ├─ components/
│ │ │ │ ├─ BannerSlot.tsx
│ │ │ │ └─ AdSlot.tsx
│ │ │ ├─ ads.mock.ts
│ │ │ └─ index.ts
│ │ ├─ notifications/
│ │ │ ├─ notifications.service.ts
│ │ │ ├─ notifications.expo.ts
│ │ │ ├─ notifications.handlers.ts
│ │ │ ├─ notifications.mock.ts
│ │ │ └─ index.ts
│ │ └─ index.ts # exports concrete service singletons
│ │
│ ├─ store/ # SINGLE Zustand store
│ │ ├─ app.store.ts # createStore + persist config inside
│ │ └─ index.ts
│ │
│ ├─ persistence/ # storage boundary (KV + optional DB)
│ │ ├─ kv/
│ │ │ ├─ kv.service.ts
│ │ │ ├─ kv.asyncstorage.ts
│ │ │ ├─ kv.keys.ts
│ │ │ └─ index.ts
│ │ ├─ db/ # optional: keep empty until needed
│ │ │ ├─ db.service.ts
│ │ │ ├─ db.sqlite.ts
│ │ │ └─ migrations/
│ │ └─ index.ts
│ │
│ ├─ ui/ # shared UI kit (NOT feature UI)
│ │ ├─ components/
│ │ │ ├─ Button.tsx
│ │ │ ├─ Card.tsx
│ │ │ ├─ Text.tsx
│ │ │ └─ ...
│ │ ├─ hooks/
│ │ │ ├─ useTheme.ts
│ │ │ └─ useHaptics.ts
│ │ ├─ theme/
│ │ │ ├─ colors.ts
│ │ │ ├─ spacing.ts
│ │ │ ├─ typography.ts
│ │ │ └─ index.ts
│ │ └─ index.ts
│ │
│ ├─ hooks/ # cross-cutting hooks (non-feature)
│ │ ├─ useAppState.ts
│ │ ├─ useConnectivity.ts
│ │ ├─ useMountedRef.ts
│ │ └─ index.ts
│ │
│ ├─ config/ # env + build flags (dev/stage/prod)
│ │ ├─ env.ts
│ │ ├─ build.ts
│ │ ├─ ids.ts # Firebase/AdMob IDs mapping
│ │ └─ index.ts
│ │
│ ├─ constants/
│ │ ├─ routes.ts
│ │ ├─ time.ts
│ │ ├─ regex.ts
│ │ └─ index.ts
│ │
│ ├─ localization/
│ │ ├─ i18n.ts
│ │ ├─ locales/
│ │ │ ├─ en.json
│ │ │ ├─ ne.json
│ │ │ └─ ...
│ │ └─ index.ts
│ │
│ ├─ assets/ # optional if you want assets under src
│ │ ├─ images/
│ │ ├─ icons/
│ │ └─ fonts/
│ │
│ ├─ utils/
│ │ ├─ assert.ts
│ │ ├─ format.ts
│ │ └─ index.ts
│ │
│ ├─ types/
│ │ ├─ global.d.ts
│ │ └─ navigation.d.ts
│ │
│ └─ bootstrap/
│ ├─ initServices.ts # init ads/analytics handlers safely
│ ├─ initNotifications.ts
│ └─ index.ts
│
├─ assets/ # Expo default assets (app config references)
│ ├─ icon.png
│ ├─ splash.png
│ ├─ adaptive-icon.png
│ └─ fonts/
│
├─ docs/
│ ├─ README.md
│ ├─ architecture/
│ │ ├─ overview.md
│ │ ├─ folder-conventions.md
│ │ ├─ services-boundaries.md
│ │ └─ env-and-builds.md
│ ├─ adr/
│ │ ├─ 0001-use-expo-router.md
│ │ ├─ 0002-use-zustand-single-store.md
│ │ ├─ 0003-firebase-native.md
│ │ └─ 0004-admob-strategy.md
│ ├─ runbooks/
│ │ ├─ release-checklist.md
│ │ ├─ debug-crashlytics.md
│ │ └─ invalid-traffic-response.md
│ └─ testing/
│ ├─ unit-testing.md
│ └─ integration-testing.md
│
├─ scripts/
│ ├─ ci/
│ │ ├─ inject-firebase-config.sh
│ │ └─ validate-env.js
│ └─ local/
│ └─ setup-dev-client.sh
│
├─ __tests__/
│ ├─ services.analytics.test.ts
│ ├─ services.crash.test.ts
│ └─ services.ads.test.ts
│
├─ .github/
│ └─ workflows/
│ ├─ build-dev.yml
│ ├─ build-stage.yml
│ └─ build-prod.yml
│
├─ app.config.ts
├─ eas.json
├─ package.json
├─ tsconfig.json
├─ jest.config.js
└─ .gitignore
2) Rules That Keep This Architecture Clean
These rules matter more than folders. If you break them, your structure will rot.
Rule A — app/ is routes only
Routes should only render feature screens.
✅ Good:
// app/(tabs)/home.tsx
import { HomeScreen } from "@/src/features/home";
export default function HomeRoute() {
return <HomeScreen />;
}
❌ Bad: data fetching, SDK calls, analytics calls inside routes.
Rule B — Features never import vendor SDKs
No @react-native-firebase/*, no react-native-google-mobile-ads, no expo-notifications inside feature folders.
Only src/services/** touches vendor SDKs.
Rule C — Shared UI is a small “UI kit”
src/ui/ should contain design tokens and reusable primitives. Feature-specific UI stays inside the feature.
3) Service Boundary Pattern (How to Integrate SDKs Safely)
Each service folder follows the same structure:
*.service.ts→ interface*.firebase.ts/*.expo.ts→ implementation (SDK import lives here)*.mock.ts→ test fakeindex.ts→ exports a singleton the app uses
This is what keeps your UI decoupled from vendors.
3.1 Analytics Service Example
Interface
// src/services/analytics/analytics.service.ts
export interface AnalyticsService {
track(event: string, props?: Record<string, any>): void;
screen(name: string, props?: Record<string, any>): void;
setUserId(userId: string | null): void;
setUserProps(props: Record<string, any>): void;
}
Firebase Implementation (SDK import stays here)
// src/services/analytics/analytics.firebase.ts
import analytics from "@react-native-firebase/analytics";
import type { AnalyticsService } from "./analytics.service";
export const firebaseAnalytics: AnalyticsService = {
track(event, props) {
void analytics().logEvent(event, props);
},
screen(name, props) {
void analytics().logScreenView({ screen_name: name, ...props });
},
setUserId(userId) {
void analytics().setUserId(userId ?? undefined);
},
setUserProps(props) {
void analytics().setUserProperties(props);
},
};
Jest Mock
// src/services/analytics/analytics.mock.ts
import type { AnalyticsService } from "./analytics.service";
export function createAnalyticsMock() {
const calls: any[] = [];
const mock: AnalyticsService = {
track: (e, p) => calls.push(["track", e, p]),
screen: (n, p) => calls.push(["screen", n, p]),
setUserId: (id) => calls.push(["setUserId", id]),
setUserProps: (p) => calls.push(["setUserProps", p]),
};
return { mock, calls };
}
Export from index
// src/services/analytics/index.ts
export { firebaseAnalytics as analytics } from "./analytics.firebase";
3.2 Crash Reporting (Crashlytics) Example
Interface
// src/services/crash/crash.service.ts
export interface CrashService {
recordError(error: unknown, context?: Record<string, any>): void;
setUserId(userId: string | null): void;
}
Firebase Implementation
// src/services/crash/crash.firebase.ts
import crashlytics from "@react-native-firebase/crashlytics";
import type { CrashService } from "./crash.service";
export const firebaseCrash: CrashService = {
recordError(error, context) {
if (context) {
const attrs: Record<string, string> = {};
for (const [k, v] of Object.entries(context)) attrs[k] = String(v);
crashlytics().setAttributes(attrs);
}
const err = error instanceof Error ? error : new Error(String(error));
crashlytics().recordError(err);
},
setUserId(userId) {
crashlytics().setUserId(userId ?? "");
},
};
Export
// src/services/crash/index.ts
export { firebaseCrash as crash } from "./crash.firebase";
3.3 Notifications (expo-notifications) Example
Interface
// src/services/notifications/notifications.service.ts
export interface NotificationsService {
requestPermission(): Promise<boolean>;
scheduleLocal(title: string, body: string, secondsFromNow: number): Promise<string>;
}
Expo Implementation
// src/services/notifications/notifications.expo.ts
import * as Notifications from "expo-notifications";
import type { NotificationsService } from "./notifications.service";
export const expoNotifications: NotificationsService = {
async requestPermission() {
const { status } = await Notifications.requestPermissionsAsync();
return status === "granted";
},
async scheduleLocal(title, body, secondsFromNow) {
return await Notifications.scheduleNotificationAsync({
content: { title, body },
trigger: { seconds: secondsFromNow },
});
},
};
Export
// src/services/notifications/index.ts
export { expoNotifications as notifications } from "./notifications.expo";
3.4 Ads (AdMob) Example: BannerSlot That Collapses on Failure
Blank white boxes ruin trust. Make ads disappear if they fail.
// src/services/ads/components/BannerSlot.tsx
import React, { useState } from "react";
import { View } from "react-native";
import { BannerAd, BannerAdSize } from "react-native-google-mobile-ads";
export function BannerSlot({ unitId, height = 60 }: { unitId: string; height?: number }) {
const [visible, setVisible] = useState(true);
if (!visible) return null;
return (
<View style={{ height }}>
<BannerAd
unitId={unitId}
size={BannerAdSize.BANNER}
onAdFailedToLoad={() => setVisible(false)}
/>
</View>
);
}
4) One Zustand Store (Simple, Predictable Client State)
Use Zustand for client state:
- UI preferences (theme)
- local-only data (drafts, cached entries)
- app flags
Keep it single store at first.
// src/store/app.store.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { kvStorage } from "@/src/persistence";
type AppState = {
theme: "light" | "dark";
feedItems: string[];
setTheme: (t: AppState["theme"]) => void;
addFeedItem: (text: string) => void;
};
export const useAppStore = create<AppState>()(
persist(
(set) => ({
theme: "light",
feedItems: [],
setTheme: (theme) => set({ theme }),
addFeedItem: (text) => set((s) => ({ feedItems: [text, ...s.feedItems] })),
}),
{
name: "app_store",
storage: kvStorage,
partialize: (state) => ({ theme: state.theme, feedItems: state.feedItems }),
}
)
);
Export:
// src/store/index.ts
export { useAppStore } from "./app.store";
5) Persistence Boundary (KV Storage)
Avoid importing AsyncStorage everywhere. Keep storage behind a boundary.
Interface
// src/persistence/kv/kv.service.ts
export interface KVStorage {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
}
AsyncStorage Implementation
// src/persistence/kv/kv.asyncstorage.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { KVStorage } from "./kv.service";
export const asyncKV: KVStorage = {
getItem: AsyncStorage.getItem,
setItem: AsyncStorage.setItem,
removeItem: AsyncStorage.removeItem,
};
Export
// src/persistence/index.ts
export { asyncKV as kvStorage } from "./kv/kv.asyncstorage";
6) Shared UI Kit + Theme (Consistent, Reusable UI)
A UI kit is not a second app. Keep it small.
Theme Tokens
// src/ui/theme/colors.ts
export const colors = {
light: { bg: "#FFFFFF", text: "#111111", primary: "#2F6FED", card: "#F4F6FA" },
dark: { bg: "#0B0F17", text: "#EDEFF2", primary: "#7AA7FF", card: "#121826" },
};
Theme Hook
// src/ui/hooks/useTheme.ts
import { useAppStore } from "@/src/store";
import { colors } from "@/src/ui/theme/colors";
export function useTheme() {
const mode = useAppStore(s => s.theme);
return { mode, colors: colors[mode] };
}
Example Shared Component
// src/ui/components/Card.tsx
import React from "react";
import { View } from "react-native";
import { useTheme } from "@/src/ui/hooks/useTheme";
export function Card({ children }: { children: React.ReactNode }) {
const { colors } = useTheme();
return (
<View style={{ backgroundColor: colors.card, padding: 12, borderRadius: 16 }}>
{children}
</View>
);
}
7) Feature Screen Example (Uses Store + Services + UI)
// src/features/home/screens/HomeScreen.tsx
import React, { useState } from "react";
import { View, TextInput, Text } from "react-native";
import { useAppStore } from "@/src/store";
import { analytics } from "@/src/services";
import { Card } from "@/src/ui/components/Card";
export function HomeScreen() {
const [text, setText] = useState("");
const items = useAppStore(s => s.feedItems);
const add = useAppStore(s => s.addFeedItem);
const onAdd = () => {
if (!text.trim()) return;
add(text.trim());
analytics.track("home_add_item", { length: text.trim().length });
setText("");
};
return (
<View style={{ padding: 16 }}>
<Card>
<TextInput value={text} onChangeText={setText} placeholder="Add item..." />
<Text onPress={onAdd} style={{ marginTop: 12, fontWeight: "600" }}>
Add
</Text>
</Card>
<View style={{ marginTop: 16 }}>
{items.map((i, idx) => (
<Text key={`${i}-${idx}`}>{i}</Text>
))}
</View>
</View>
);
}
Export it via feature index:
// src/features/home/index.ts
export { HomeScreen } from "./screens/HomeScreen";
8) Bootstrap (Safe Startup Wiring)
Avoid side effects scattered across files. Keep startup in one place.
// src/bootstrap/initServices.ts
import { analytics } from "@/src/services";
export function initServices() {
analytics.track("app_start");
}
Then call it once:
// app/_layout.tsx
import React, { useEffect } from "react";
import { Stack } from "expo-router";
import { initServices } from "@/src/bootstrap";
export default function RootLayout() {
useEffect(() => {
initServices();
}, []);
return <Stack />;
}
9) The One Rule That Prevents Vendor Lock-In
Only src/services/** imports vendor SDKs.
If you stick to that, you can:
- swap Firebase analytics later
- swap notifications strategy later
- change ad network later
without rewriting your entire UI.
10) When to Add More (And When Not To)
Add these only when needed:
src/services/api/+src/services/query/→ when remote APIs appearsrc/persistence/db/→ when KV storage is not enoughsrc/services/sync/→ when offline-first reliability is required
If you add them early, you’ll carry dead weight.
Leave a Reply