A Practical Guide to a Feature-Based Expo Architecture

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 fake
  • index.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 appear
  • src/persistence/db/ → when KV storage is not enough
  • src/services/sync/ → when offline-first reliability is required

If you add them early, you’ll carry dead weight.

Leave a Reply

Your email address will not be published. Required fields are marked *