Modern Expo + React Navigation template with a small, production-oriented baseline: typed navigation, a theme system, i18n, and a lightweight state setup.
- React Native: 0.88.0-rc.3 + React 19.3.0 (New Architecture)
- Expo: SDK 58 (preview / expo@next)
- Navigation: React Navigation 7 (tabs, stacks, modals)
- TypeScript: strict type checking
- Package manager: bun (
bun install,bun.lock)
- Styling: react-native-unistyles for RN views +
@expo/uistyle/textStyle on native trees - Theming: light/dark mode + system theme sync (Legend State store bridged to
UnistylesRuntime) - Design tokens: colors, spacing, typography, radii, shadows
- UI:
@expo/uiuniversal components (Host, Button, Text, Column, Row, etc.) - Pressable: local gesture-handler pressable kept for custom hit targets
- Layouts: Base/Bare/Modal layouts for screens
- State: Legend State with MMKV persistence
- Forms: React Hook Form
- List rendering: LegendList v3 utility wrapper
- Monetization: RevenueCat utility
- Quality: Oxlint + Oxfmt + Lefthook
src/
├── components/ # Common, layouts
├── hooks/ # App-level hooks (debounce/throttle/etc.)
├── i18n/ # i18next setup + locales (en)
├── navigation/ # Navigators, linking, param lists
├── providers/ # Top-level providers (ErrorBoundary, etc.)
├── screens/ # Screen UI rendered by navigators
├── stores/ # App stores (settings, etc.)
├── theme/ # Theme system (tokens, hooks, store, unistyles registry)
├── types/ # Shared TS types
└── utils/ # Utilities (storage, logger, date, etc.)
brew install oven-sh/bun/bun # or: curl -fsSL https://bun.sh/install | bash
bun install
bun run startRun native:
bun run ios
bun run androidAdd or remove dependencies with bun add / bun remove; bun owns the lockfile (bun.lock), so npm install, pnpm install, and expo install must not be used to change the tree.
Development and production installs can sit side by side. app.json holds the production identity; app.config.ts suffixes it when APP_VARIANT is not production.
| Variant | APP_VARIANT |
Name | Bundle ID / package |
|---|---|---|---|
| Dev (default) | development or dev |
my-template-app (Dev) |
com.mytemplateproject.dev |
| Preview | preview |
my-template-app (Preview) |
com.mytemplateproject.preview |
| Production | production |
my-template-app |
com.mytemplateproject |
Local scripts (start, ios, android, prebuild) set APP_VARIANT=development. EAS profiles in eas.json set the same variable per build. Only the development build registers the generated exp+<slug> scheme so the Metro QR code opens the Dev app.
Read the resolved variant at runtime with Constants.expoConfig?.extra?.variant. Register each identifier separately with Sentry, and any other service keyed to bundle ID.
Switching a local native project to another variant:
APP_VARIANT=production bunx expo prebuild --cleanUse APP_VARIANT=development bunx expo prebuild --clean before the next dev session so CLI schemes point at the Dev app again.
Use @expo/ui universal components wrapped in Host.
import { Button, Host, Text } from "@expo/ui";
export function Example() {
return (
<Host matchContents>
<Text textStyle={{ fontSize: 24, fontWeight: "600" }}>Welcome</Text>
<Button label="Continue" onPress={() => {}} />
</Host>
);
}RN views are styled with react-native-unistyles. Import StyleSheet from react-native-unistyles (never react-native), define styles at module level, and reach theme tokens through the theme callback argument. Styles update automatically on light/dark change — no hook needed.
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
const styles = StyleSheet.create((theme) => ({
card: {
padding: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface.elevated,
},
}));
export function Card() {
return <View style={styles.card}>{/* ... */}</View>;
}Avoid inline style={{...}} on RN views and theme reads in JSX. @expo/ui props (style, textStyle on Host/Column/Text, …) are @expo/ui's own API and stay inline.
The unistyles themes are registered in src/theme/unistyles.ts; the light/dark decision itself lives in the Legend State theme store (themePrefs$) and is bridged to UnistylesRuntime.setTheme on every mode change.
import { Button, Host, Text } from "@expo/ui";
import { useTheme } from "@/theme";
export function ThemeExample() {
const { mode, isDark, toggleMode } = useTheme();
return (
<Host colorScheme={isDark ? "dark" : "light"} matchContents>
<Text>
Mode: {mode} ({isDark ? "dark" : "light"})
</Text>
<Button label="Toggle theme" onPress={toggleMode} variant="outlined" />
</Host>
);
}This template ships with English resources by default. Add more languages by extending src/i18n/locales/* and resources in src/i18n/index.ts.
Subscriptions use RevenueCat (react-native-purchases + react-native-purchases-ui). Set the public SDK keys in .env:
EXPO_PUBLIC_REVENUECAT_IOS_API_KEYEXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY
The wrapper expects a premium entitlement and paywall placements settings and onboarding_v1 in the RevenueCat dashboard. Initialization is skipped when the current platform key is missing.
Expo Go can load the SDK in Preview API Mode, but real purchases require a development build. After adding or changing these native packages, remake the native client (bun run prebuild or an EAS development build).
bun run start- start Expo dev serverbun run ios- run iOS buildbun run android- run Android buildbun run lint- run Oxlint with auto-fixbun run format- check formatting with Oxfmtbun run format:write- format with Oxfmtbun run typecheck- TypeScript typecheckbun run prebuild- regenerate native projects (APP_VARIANT=developmentwithprebuild:dev)