# Engineering Reference Project structure, tooling, builds, releases, and platform integration for Expo / React Native. ## Project Structure ### Standard Layout ``` my-app/ app/ File-based routing (Expo Router) _layout.tsx Root layout: providers, fonts, NativeTabs index.tsx → / (tabs)/ _layout.tsx Tab navigator home.tsx → /home profile.tsx → /profile (auth)/ login.tsx → /login (group, not in URL) register.tsx → /register user/ [id].tsx → /user/:id [id]/ posts.tsx → /user/:id/posts api/ users+api.ts → /api/users (server route) users/[id]+api.ts → /api/users/:id components/ Reusable UI components ui/ Primitive components (Button, Input, Card) shared/ Composed components (UserAvatar, PostCard) hooks/ Custom React hooks stores/ Zustand / Jotai stores services/ API client, external service wrappers utils/ Pure utility functions constants/ App-wide constants (colors, spacing, config) types/ Shared TypeScript types/interfaces assets/ Static assets (images, fonts, icons) scripts/ Build/dev helper scripts app.json Expo config eas.json EAS Build config tsconfig.json TypeScript config with path aliases .env Environment variables .env.development .env.production ``` ### Route Conventions | File | Route | Notes | |------|-------|-------| | `app/index.tsx` | `/` | Home/root | | `app/about.tsx` | `/about` | Static route | | `app/user/[id].tsx` | `/user/:id` | Dynamic segment | | `app/user/[...rest].tsx` | `/user/*` | Catch-all | | `app/(tabs)/home.tsx` | `/home` | Group (not in URL) | | `app/(a,b)/shared.tsx` | Shared between tabs `a` and `b` | Multi-group | | `app/_layout.tsx` | Layout wrapper | No route | | `app/+not-found.tsx` | 404 page | | | `app/api/users+api.ts` | `/api/users` | Server route | **Rules**: - Routes only in `app/` — no components, types, or utils - Always have a route matching `/` - Use kebab-case filenames (`user-profile.tsx`, not `UserProfile.tsx`) - Remove old route files when restructuring ### Path Aliases ```json // tsconfig.json { "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, "paths": { "@/*": ["./*"], "@components/*": ["./components/*"], "@hooks/*": ["./hooks/*"], "@stores/*": ["./stores/*"], "@services/*": ["./services/*"], "@utils/*": ["./utils/*"], "@constants/*": ["./constants/*"], "@types/*": ["./types/*"] } } } ``` ```tsx // ✗ Relative imports — fragile, change with file moves import { Button } from "../../../components/ui/Button"; // ✓ Alias imports — stable import { Button } from "@components/ui/Button"; ``` Metro resolves `paths` and `baseUrl` from `tsconfig.json` natively — no extra config needed. If using a non-Metro bundler, install `babel-plugin-module-resolver`: ```js // babel.config.js — only needed for non-Metro bundlers module.exports = { presets: ["babel-preset-expo"], plugins: [ ["module-resolver", { root: ["./"], alias: { "@": "./", "@components": "./components", "@hooks": "./hooks", "@stores": "./stores", "@services": "./services", }, }], ], }; ``` ### Components Organization ``` components/ ui/ Atomic components Button.tsx Input.tsx Card.tsx Badge.tsx index.ts Barrel export shared/ Composed components UserAvatar.tsx PostCard.tsx EmptyState.tsx layout/ Layout components Screen.tsx SafeArea wrapper Header.tsx ``` ```tsx // components/ui/index.ts — barrel export export { Button } from "./Button"; export { Input } from "./Input"; export { Card } from "./Card"; // Usage import { Button, Input, Card } from "@components/ui"; ``` ### Design System ``` constants/ colors.ts Color palette + semantic colors spacing.ts 8pt grid spacing values typography.ts Font families, sizes, weights theme.ts Combined theme object ``` ```tsx // constants/colors.ts export const colors = { primary: "#6200EE", secondary: "#03DAC6", background: "#FFFFFF", surface: "#F5F5F5", error: "#B00020", text: { primary: "#000000DE", secondary: "#0000008A" }, } as const; // constants/spacing.ts — 8pt grid export const spacing = { xs: 4, sm: 8, md: 16, lg: 24, xl: 32, xxl: 48, } as const; // constants/typography.ts export const typography = { sizes: { xs: 12, sm: 14, md: 16, lg: 20, xl: 24, xxl: 32 }, weights: { regular: "400", medium: "500", semibold: "600", bold: "700" }, } as const; ``` ### Services Layer ``` services/ api/ client.ts Base fetch client with auth headers users.ts User-related API calls posts.ts Post-related API calls storage/ secure-store.ts Wrapper for expo-secure-store async-storage.ts Wrapper for AsyncStorage notifications/ push.ts Expo push notification helpers ``` ```tsx // services/api/client.ts const BASE_URL = process.env.EXPO_PUBLIC_API_URL!; export const api = { get: (path: string, token?: string) => fetch(`${BASE_URL}${path}`, { headers: { Authorization: token ? `Bearer ${token}` : "" }, }).then(async (r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise; }), // post/put/delete follow same pattern — add method, Content-Type, JSON.stringify(body) }; ``` ### Monorepo ``` my-monorepo/ apps/ mobile/ Expo app (all native deps here) package.json app.json web/ Next.js app package.json packages/ ui/ Shared UI components (no native deps) package.json utils/ Shared utilities (no native deps) package.json types/ Shared TypeScript types package.json package.json Root workspace config ``` ```json // Root package.json { "private": true, "workspaces": ["apps/*", "packages/*"], "scripts": { "mobile": "yarn workspace @my/mobile start", "web": "yarn workspace @my/web dev" } } ``` **Monorepo rules**: - **Keep native dependencies in the app package** (`apps/mobile`) — never in shared packages - Use a single version of each dependency across all packages - Shared packages should be pure JS/TS only ### Environment Variables ```bash # .env (committed, non-sensitive defaults) EXPO_PUBLIC_APP_NAME=MyApp EXPO_PUBLIC_API_VERSION=v1 # .env.development (local only, gitignored) EXPO_PUBLIC_API_URL=http://localhost:3000 # .env.production (CI/CD only, gitignored) EXPO_PUBLIC_API_URL=https://api.production.example.com ``` ```tsx // types/env.d.ts declare global { namespace NodeJS { interface ProcessEnv { EXPO_PUBLIC_API_URL: string; EXPO_PUBLIC_APP_NAME: string; } } } export {}; ``` ### Custom Fonts ```bash npx expo install expo-font ``` ```json // app.json — config plugin (preferred over manual linking) { "expo": { "plugins": [ ["expo-font", { "fonts": ["./assets/fonts/Inter-Regular.ttf"] }] ] } } ``` ```tsx // app/_layout.tsx import { useFonts } from "expo-font"; import { SplashScreen } from "expo-router"; SplashScreen.preventAutoHideAsync(); export default function RootLayout() { const [loaded] = useFonts({ "Inter-Regular": require("../assets/fonts/Inter-Regular.ttf") }); useEffect(() => { if (loaded) SplashScreen.hideAsync(); }, [loaded]); if (!loaded) return null; return ; } ``` ## Development Builds Expo Go (`npx expo start`) covers most use cases out of the box. Switch to a custom dev client when your project uses native code that Expo Go doesn't bundle — for example, a local Expo module in `modules/`, an Apple target (widget, app clip), or a community native library that isn't pre-installed in Expo Go. ### Creating a Dev Client ```bash # Option A — cloud build, push to TestFlight / internal distribution eas build -p ios --profile development --submit # Option B — build locally (requires Xcode / Android Studio) eas build -p ios --profile development --local ``` After installing on the device or simulator, connect with: ```bash npx expo start --dev-client ``` ### eas.json Profile ```json { "build": { "development": { "developmentClient": true, "distribution": "internal", "autoIncrement": true } } } ``` ## Upgrading the SDK ### Routine Upgrade ```bash npx expo install expo@latest --fix # bumps Expo + aligns peer deps npx expo-doctor # surfaces remaining mismatches ``` Then test on both platforms and rebuild the dev client if you use one. ### Trying a Pre-release Pre-release versions are tagged `@next` on npm: ```bash npx expo install expo@next --fix ``` ### Notable Changes Across SDK Versions | Version | What Changed | |---------|-------------| | SDK 53 | New Architecture on by default; Expo Go requires it; `autoprefixer` no longer needed | | SDK 54 | React 19 (`use()` replaces `useContext`, `` replaces ``, `forwardRef` removed); React Compiler available; `EXPO_USE_FAST_RESOLVER` removed | | SDK 55 | NativeTabs API updated — Icon/Label/Badge accessed via `NativeTabs.Trigger.*` | | Ongoing | `expo-av` deprecated in favor of `expo-audio` + `expo-video` | ### React 19 Patterns (SDK 54+) ```tsx // Context import { use, createContext } from "react"; const ThemeCtx = createContext("light"); // consume: const theme = use(ThemeCtx); // provide: ... // Refs — no more forwardRef function Field({ ref, ...props }: Props & { ref?: React.Ref }) { return ; } ``` ### Opting Out of New Architecture If a third-party library breaks under the New Architecture: ```json { "expo": { "newArchEnabled": false } } ``` Check compatibility at [reactnative.directory](https://reactnative.directory). ## Releasing ### Build Profiles A typical `eas.json` has three tiers: ```json { "cli": { "version": ">= 16.0.1", "appVersionSource": "remote" }, "build": { "development": { "developmentClient": true, "distribution": "internal", "autoIncrement": true }, "preview": { "distribution": "internal", "autoIncrement": true }, "production": { "autoIncrement": true, "ios": { "resourceClass": "m-medium" } } } } ``` ### Building & Submitting ```bash # Build for both platforms eas build -p ios --profile production eas build -p android --profile production # Build + submit in one step eas build -p ios --profile production --submit # Or submit a finished build separately eas submit -p ios eas submit -p android ``` ### Store Submission Notes **iOS** — Run `eas credentials` to set up signing. Create the app record in App Store Connect, fill metadata, then `--submit` pushes the build to TestFlight automatically. **Android** — Create a Google Play service account, download its JSON key, and reference it in `eas.json` under `submit.production.android.serviceAccountKeyPath`. The first build must be uploaded manually through Play Console; subsequent builds use `eas submit`. ### Over-the-Air Updates For JS-only changes (no new native code), skip the full build/review cycle: ```bash npx expo install expo-updates eas update --branch production --message "Fix checkout rounding error" ``` ### Web Hosting ```bash npx expo export -p web eas deploy # preview URL eas deploy --prod # production ``` ## CI/CD with EAS Workflows Workflow files live in `.eas/workflows/` and follow a YAML schema: ```yaml # .eas/workflows/release.yml name: Release to stores on: push: branches: [main] jobs: build: type: build params: platform: all profile: production submit: type: submit needs: [build] params: platform: all profile: production ``` ```yaml # .eas/workflows/pr-check.yml name: PR check on: pull_request: branches: [main] jobs: preview-build: type: build params: platform: all profile: preview ``` ## DOM Components The `"use dom"` directive lets you render web-only code inside a WebView on native while running it as standard DOM on web. Useful for libraries that depend on browser APIs (chart libraries, rich text editors, syntax highlighters). ```tsx // components/RichPreview.tsx "use dom"; import ReactMarkdown from "react-markdown"; export default function RichPreview({ markdown }: { markdown: string }) { return {markdown}; } ``` ```tsx // app/note/[id].tsx — native screen import RichPreview from "@/components/RichPreview"; export default function NoteScreen() { const { content } = useNote(); return ( ); } ``` Rules: - `"use dom"` must be the first statement in the file - One default export per file; cannot be mixed with native components - Props must be serializable (strings, numbers, booleans, plain objects/arrays) - Async function props bridge native actions into the webview (e.g., `onSave: (data) => Promise`) - Cannot be used in `_layout.tsx` files - Router hooks that read native navigation state (`useLocalSearchParams`, `usePathname`, etc.) must be called in the native parent and passed as props