Modern Expo + Expo Router template with a small, production-oriented baseline: file-based navigation, a theme system, i18n, and a lightweight state setup.
- React Native: 0.88.0-rc.4 + React 19.3.0 (New Architecture)
- Expo: SDK 58
- Navigation: Expo Router (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.) - 2D Graphics: react-native-skia 3.0.3 (unscoped Skia 3); tab one renders an animated Skia canvas (
src/screens/tab-one/pulse-canvas.tsx) colored from the theme - Pressable: local gesture-handler pressable kept for custom hit targets
- Layouts: Base/Bare/Modal layouts for screens
- State: Legend State with MMKV persistence
- Database: WatermelonDB (SQLite adapter, JSI mode) in
src/data; tab two adds and lists notes - List rendering: LegendList v3 utility wrapper
- Monetization: RevenueCat utility
- Quality: Oxlint + Oxfmt + Lefthook
app/ # Expo Router layouts & route wrappers
├── (tabs)/ # Tab routes & tab layout
├── _layout.tsx # Root layout & providers
├── modal-one.tsx # Modal route
├── stack-one.tsx # Stack push route
└── +not-found.tsx # Unmatched route
src/
├── components/ # Common, layouts
├── data/ # WatermelonDB schema, migrations, models, database
├── hooks/ # App-level hooks (refresh control, etc.)
├── i18n/ # i18next setup + locales (en)
├── providers/ # Top-level providers (ErrorBoundary, etc.)
├── screens/ # Screen UI rendered by routes
├── 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. When APP_VARIANT is unset (local start, ios, android, prebuild) it defaults to development; production must be requested explicitly.
| Variant | APP_VARIANT |
Name | Bundle ID / package |
|---|---|---|---|
| Dev (default) | unset, 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 |
EAS profiles in eas.json set APP_VARIANT explicitly 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.
Local persistence uses WatermelonDB on the SQLite adapter with jsi: false (the native bridge dispatcher on both platforms; the 0.28 JSI adapter needs React Native's removed RCTCxxBridge).
src/data/schema.ts,src/data/migrations.ts— schema and migrations. Bumpversionand add a migration step together.src/data/models/— models without decorators: declare typed fields and bind them withdefineColumns(src/data/define-columns.ts), e.g.src/data/models/note.ts. Register new models inmodelClassesinsrc/data/database.ts.src/data/database.ts— thedatabaseinstance anddatabaseReady(await it before the first query or write; Android schema setup is async).DatabaseProvideris composed intoMainProvider, souseDatabase()works anywhere under the root layout.src/screens/tab-two— add/list example (useNotes.tsobserves a query).
Native setup comes from plugins/withWatermelonDb.js (referenced in app.json): it adds the npm-vendored simdjson pod and builds WatermelonDB/simdjson as static libraries in the Podfile. expo-build-properties sets ios.useFrameworks: "static". patches/watermelondb@0.28.0.patch (wired through patchedDependencies in package.json) strips the iOS JSI installer and enables buildConfig on Android. Needs a development build — WatermelonDB does not run in Expo Go. Metro lists @nozbe/watermelondb in nonInlinedRequires. After changing any of this, rebuild native (bun run prebuild or an EAS development build).
react-native-skia (the unscoped Skia 3 package, successor of @shopify/react-native-skia) is installed. The tab one demo animates a Canvas with a Reanimated shared value and takes its colors from useTheme(), so it follows light/dark.
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 (check only)bun run lint:fix- run Oxlint with auto-fixbun run format- check formatting with Oxfmtbun run format:write- format with Oxfmtbun run typecheck- TypeScript typecheckbun run verify:locales- verify i18n key parity across localesbun run prebuild- regenerate native projects (APP_VARIANT=developmentwithprebuild:dev)