Skip to content

๐Ÿ“ฑ Touli Mobile โ€” Project Structure โ€‹

Expo (React Native) app ยท lives at apps/mobile in the Touli Nx monorepo ยท shares libs/types with apps/server

See also: project-structure.md โ€” the client/server/libs side of the monorepo.

ExpoReact NativeExpo RouterTypeScriptReact QueryZustand


Contents โ€‹

  1. Overview
  2. Architecture flow
  3. Full folder tree
  4. app/ โ€” routing & protected routes
  5. features/
  6. lib/
  7. Path aliases
  8. Tech stack

Overview โ€‹

AreaPathWhat it is
๐Ÿ“‚ Routingapps/mobile/src/app/Expo Router file-based routes โ€” groups, layouts, screens
๐Ÿงฉ Featuresapps/mobile/src/features/Screen components + feature-local UI, grouped by domain
๐Ÿงฐ Shared libapps/mobile/src/lib/Everything cross-feature: API layer, design system, i18n, hooks, constants
๐Ÿ”— Shared w/ backendlibs/types@workspace/types/* โ€” interfaces, enums, DTOs, API path constants used by both mobile and server

The app is not a single monolithic utils.ts codebase โ€” lib/ is deliberately split by what kind of thing each file is (a network call vs. a React Query hook vs. a constant vs. a generic helper), not just dumped into one folder. See lib/ for the exact rationale of each subfolder.


Architecture flow โ€‹

mermaid
flowchart LR
    U["๐Ÿ“ฑ User"] --> R["Expo Router\n(app/)"]
    R --> F["Feature screens\n(features/*)"]
    F --> Q["lib/queries\nReact Query hooks"]
    Q --> S["lib/services\naxios calls"]
    S --> API["apps/server\nExpress REST API"]
    F --> C["lib/components/ui\nDesign system"]
    F --> I["lib/i18n\nen / ar"]
    API --> DB[("PostgreSQL")]

    style U fill:#fce7f3,stroke:#db2777
    style R fill:#eff6ff,stroke:#2563eb
    style F fill:#eff6ff,stroke:#2563eb
    style Q fill:#f5f3ff,stroke:#7c3aed
    style S fill:#f5f3ff,stroke:#7c3aed
    style C fill:#f5f3ff,stroke:#7c3aed
    style I fill:#f5f3ff,stroke:#7c3aed
    style API fill:#f0fdf4,stroke:#16a34a
    style DB fill:#fef3c7,stroke:#d97706

Screens never call axios directly and never talk to the backend without going through lib/queries โ†’ lib/services. See lib/ for why those are two separate folders.


Full folder tree โ€‹

text
apps/mobile/
โ”œโ”€โ”€ app.json / app.config.ts        # Expo app config
โ”œโ”€โ”€ eas.json                        # EAS Build profiles
โ”œโ”€โ”€ metro.config.js                 # Metro bundler config
โ”œโ”€โ”€ env.ts                          # Typed process.env wrapper
โ””โ”€โ”€ src/
    โ”œโ”€โ”€ app/                        # Expo Router โ€” file-based routes (see below)
    โ”œโ”€โ”€ features/                   # Screens + feature-local components, one folder per domain
    โ”‚   โ”œโ”€โ”€ auth/                   # Signup (Google/Apple via thirdweb), Welcome Back, auth store, route guards
    โ”‚   โ”œโ”€โ”€ onboarding/              # Avatar โ†’ name โ†’ age โ†’ consents โ†’ permissions โ†’ interests โ†’ first challenge
    โ”‚   โ”œโ”€โ”€ home/                    # Home tab: points card, active challenge, badges, leaderboard
    โ”‚   โ”œโ”€โ”€ challenges/               # Categories, challenge lists, active-challenge detail + task flow
    โ”‚   โ”œโ”€โ”€ gifts/                    # Gift center: pending gifts, claim flow, activity
    โ”‚   โ”œโ”€โ”€ deeds/                    # Deed submission (QR / photo+GPS+peer / AI review)
    โ”‚   โ”œโ”€โ”€ settings/                 # Theme, language, account settings
    โ”‚   โ”œโ”€โ”€ notifications/            # Notifications list
    โ”‚   โ”œโ”€โ”€ shop/                     # Shop tab (placeholder)
    โ”‚   โ”œโ”€โ”€ profile/                  # Profile tab
    โ”‚   โ””โ”€โ”€ feed/                     # โš ๏ธ scaffold demo (JSONPlaceholder-style), not wired into any nav
    โ””โ”€โ”€ lib/                         # Cross-feature code, split by kind (see "lib/" section)
        โ”œโ”€โ”€ services/                 # Raw backend API calls (axios) - one *.service.ts per domain
        โ”œโ”€โ”€ queries/                  # React Query hooks (+ provider) - one *.queries.ts per domain
        โ”œโ”€โ”€ utils/                    # Generic helpers with no backend/query dependency
        โ”œโ”€โ”€ constants/                 # Static constant values (routes, colors, assets, theme tokens)
        โ”œโ”€โ”€ config/                    # Static third-party config objects (navigation theme)
        โ”œโ”€โ”€ hooks/                     # Stateful, MMKV-persisted hooks (onboarding progress, consents, ...)
        โ”œโ”€โ”€ i18n/                      # i18next setup, split per-section locale files (en/ar)
        โ””โ”€โ”€ components/                 # ui/ design system, icons/, layouts/, common/

app/ โ€” routing & protected routes โ€‹

Expo Router maps files under src/app/ to routes. Group folders like (app), (auth), (onboarding) don't appear in the URL โ€” they exist purely to scope a layout and a set of guards.

text
app/
โ”œโ”€โ”€ _layout.tsx              # Root layout: providers + <Stack.Protected> guards (see below)
โ”œโ”€โ”€ index.tsx                 # Splash screen โ€” decides where a cold start lands
โ”œโ”€โ”€ (auth)/                   # Reachable only when signed out
โ”‚   โ”œโ”€โ”€ _layout.tsx
โ”‚   โ”œโ”€โ”€ login.tsx              # "Welcome Back" (returning, already-tokened users)
โ”‚   โ””โ”€โ”€ signup.tsx             # Google/Apple OAuth via thirdweb
โ”œโ”€โ”€ (onboarding)/              # Reachable for first-time installs, or signed-in users mid-onboarding
โ”‚   โ”œโ”€โ”€ _layout.tsx
โ”‚   โ”œโ”€โ”€ onboarding.tsx          # Intro screen
โ”‚   โ”œโ”€โ”€ avatar.tsx ยท name.tsx ยท age-consent.tsx ยท privacy-consent.tsx
โ”‚   โ”œโ”€โ”€ parental-consent.tsx ยท permissions.tsx
โ”‚   โ”œโ”€โ”€ challenge-interests.tsx ยท gift-interests.tsx ยท first-challenge.tsx
โ”œโ”€โ”€ (app)/                    # Reachable only when signed in AND fully onboarded
โ”‚   โ”œโ”€โ”€ _layout.tsx             # Tab bar: Home ยท Challenges ยท Gifts ยท Shop ยท Profile
โ”‚   โ”œโ”€โ”€ index.tsx                # Home tab
โ”‚   โ”œโ”€โ”€ notifications.tsx ยท shop.tsx ยท test.tsx
โ”‚   โ”œโ”€โ”€ challenges/               # Stack: list โ†’ [category] โ†’ active-challenge
โ”‚   โ”œโ”€โ”€ gifts/                    # Stack: list โ†’ deed submission flow โ†’ claim-gift
โ”‚   โ””โ”€โ”€ profile/                  # Stack: profile โ†’ settings
โ””โ”€โ”€ feed/                      # โš ๏ธ scaffold demo routes, not linked from any real navigation

Protected Routes (Expo Router <Stack.Protected>) โ€‹

Route-group access used to be enforced by hand-rolled <Redirect> checks duplicated across (app)/_layout.tsx, (auth)/_layout.tsx, and (onboarding)/_layout.tsx โ€” three places computing the same thing off the same state, with a real risk of two of them disagreeing and bouncing the user back and forth forever.

That's now centralized in features/auth/use-protected-routes.ts, consumed once by the root layout:

tsx
// app/_layout.tsx
const { canAccessApp, canAccessAuth, canAccessOnboarding } = useProtectedRoutes();

<Stack>
  <Stack.Screen name="index" />

  <Stack.Protected guard={canAccessApp}>
    <Stack.Screen name="(app)" />
  </Stack.Protected>

  <Stack.Protected guard={canAccessAuth}>
    <Stack.Screen name="(auth)" />
  </Stack.Protected>

  <Stack.Protected guard={canAccessOnboarding}>
    <Stack.Screen name="(onboarding)" />
  </Stack.Protected>
</Stack>;
GuardTrue when
canAccessAppsigned in and fully onboarded
canAccessAuthsigned out
canAccessOnboardingfirst-time install, or signed in but onboarding isn't finished

All three stay false until the stored session has finished hydrating, so a cold start can't decide access based on the default idle status. If a guard flips to false while its group is active, Expo Router redirects to the anchor route (index) โ€” which is exactly the splash screen that decides where to actually send the user next.

The three group layouts no longer contain any auth logic at all โ€” they just render their Tabs/Stack.


features/ โ€‹

One folder per product domain. Inside each:

text
features/<domain>/
โ”œโ”€โ”€ <domain>-screen.tsx         # Top-level screen(s), rendered by an app/ route file
โ”œโ”€โ”€ components/                  # Feature-local components (not shared elsewhere)
โ”œโ”€โ”€ mappers.ts                    # API response โ†’ UI-shape adapters (challenges only, currently)
โ””โ”€โ”€ use-<domain>-store.tsx        # Zustand store, if the feature needs client-only state (auth only)

Screens fetch data via lib/queries hooks (useMyChallenges, useChallengeDetail, useMyWallet, โ€ฆ) โ€” never by calling lib/services functions directly from a component.

features/feed/ is scaffold/demo code left over from the project template (JSONPlaceholder-style posts, react-query-kit instead of the app's own query pattern) โ€” it isn't linked from any tab or button. login-form.tsx, login-form.test.tsx, style-demo/, and the duplicate app/login.tsx route were the same kind of leftover and have already been removed this cycle.


lib/ โ€‹

The most important structural decision in this app: lib/ is split by what kind of thing a file is, not dumped flat. This mirrors (and was directly inspired by) the client/server split documented for apps/client/apps/server, adapted for a mobile app with no SSR and its own local-storage/auth concerns.

FolderContainsRule of thumb
services/client.ts (axios instance + refresh interceptor), thirdweb.ts, one *.service.ts per domainRaw network calls only โ€” no React, no hooks
queries/provider.tsx (React Query provider), one *.queries.ts per domainReact Query hooks wrapping services/ functions โ€” the only place useQuery/useMutation should be called from for server data
constants/routes.constants.ts, colors.constants.ts, assets.constants.ts, theme.constants.tsStatic values with zero logic โ€” route paths, hex colors, image require()s, light/dark tokens
config/navigation-theme.ts, use-theme-config.tsxStatic config objects for a third-party library (React Navigation's Theme shape) โ€” distinct from constants/, which is this app's own values
utils/device.ts, form.ts, query.ts, api-error.ts, storage.ts, token-storage.ts, error-toast.ts, store.tsGeneric helpers with no backend dependency โ€” MMKV wrappers, a zustand selector factory, error-message formatting
hooks/use-is-first-time.tsx, use-selected-theme.tsx, use-last-onboarding-route.ts, use-age-category.tsx, use-onboarding-data.ts, use-privacy-consents.tsxStateful, MMKV-persisted hooks โ€” genuinely reactive, re-render on change
i18n/index.tsx (init), resources.ts (merges locale files), language.ts, key-path.ts, rtl.ts, locales/<section>/{en,ar}.jsoni18next setup; translations split one JSON file per top-level section, per language
components/ui/ (atoms โ†’ molecules โ†’ organisms design system), icons/, layouts/, common/ (EmptyState, ComingSoonBadge, โ€ฆ)Presentational only โ€” no data fetching

Why services/ and queries/ are separate folders โ€‹

A *.service.ts file is a plain async function hitting an endpoint (getChallengeDetail(id), joinChallenge(id, mode)). A *.queries.ts file wraps those in useQuery/useMutation, owning cache keys and invalidation. Keeping them apart means:

  • A service function can be reused outside React Query (e.g. imperative calls in the auth store) without dragging hooks in.
  • Cache-invalidation logic for a whole domain lives in one file, not scattered next to each individual fetch function.

lib/i18n/locales/ layout โ€‹

text
locales/
โ”œโ”€โ”€ common/{en,ar}.json
โ”œโ”€โ”€ onboarding/{en,ar}.json
โ”œโ”€โ”€ settings/{en,ar}.json
โ”œโ”€โ”€ notifications/{en,ar}.json
โ”œโ”€โ”€ challenges/{en,ar}.json
โ”œโ”€โ”€ gifts/{en,ar}.json
โ”œโ”€โ”€ deeds/{en,ar}.json
โ”œโ”€โ”€ home/{en,ar}.json
โ””โ”€โ”€ auth/{en,ar}.json

resources.ts imports every file and re-merges them into the same { common: {...}, home: {...}, ... } shape i18next always saw โ€” so t('home.points.label')-style calls anywhere in the app are unaffected by the split.


Path aliases โ€‹

AliasResolves to
@/lib/*apps/mobile/src/lib/*
@/features/*apps/mobile/src/features/*
@workspace/types/*libs/types/src/* โ€” interfaces, enums, DTOs, API path constants (shared with apps/server)
@workspace/modelslibs/models/src โ€” Sequelize models (server-only, not used by mobile)
@workspace/ui/icons/*libs/ui/src/icons/* โ€” shared SVG logo assets

Within lib/, every subfolder (services/, queries/, utils/, constants/, config/, hooks/) exposes an index.ts barrel, so most cross-feature imports read @/lib/services, @/lib/queries, @/lib/constants, etc. rather than reaching into an individual file.


Tech stack โ€‹

LayerLibraryVersion
FrameworkExpo~54.0
FrameworkReact Native0.81.6
FrameworkReact19.1.4
RoutingExpo Router~6.0.22
Server stateTanStack React Query^5.90.19
Client stateZustand5.0.3
Local storagereact-native-mmkv^4.3.2
Secure storageexpo-secure-store~15.0
Auth / walletthirdweb^5.120.1
HTTPaxios^1.15.2
i18ni18next / react-i18next^25.8.0 / ^16.5.3
Animationreact-native-reanimated~4.1.6
Bottom sheets@gorhom/bottom-sheet^5.2.8
Forms@tanstack/react-form^1.27.7
MonorepoNx^20.7.1
LanguageTypeScript~5.9.2

Touli ยท apps/mobile ยท keep this file in sync when lib/ or app/ structure changes