Production-ready Expo starter for HNG teams using Vertical Slice Architecture. Clone and ship — the structure scales, the defaults work out of the box.
| Layer | Choice |
|---|---|
| Framework | Expo SDK 55 + Expo Router v4 |
| Language | TypeScript (strict) |
| Package manager | pnpm (hoisted) |
| Navigation | File-based routing with guarded route groups |
| State | Zustand v5 (one store per feature slice) |
| Data fetching | TanStack Query v5 + Axios |
| Forms | React Hook Form v7 + Zod v3 |
| Storage | expo-secure-store (tokens) · AsyncStorage (prefs) |
| Theming | Custom ThemeContext · light / dark / system |
| Linting | ESLint (eslint-config-expo flat) |
| Formatting | Prettier |
| CI | GitHub Actions → APK → Appetize + artifact |
git clone <repo-url>
cd react-native-starter
pnpm install
pnpm startDemo credentials (no backend needed): jeffery@logickoder.dev / Password1
Expo Go caveat (SDK 55): The Expo Go versions on the App Store and Google Play may lag behind SDK 55 releases. See expo/expo#43699.
- Android — run
pnpm startthen choose "Open on Android" from the dev menu. This downloads the latest Expo Go build directly to the device, bypassing the Play Store version.- iOS — use the Expo Go TestFlight build (SDK 55 compatible) instead of the App Store version.
- Recommended — use a development build to avoid Expo Go version constraints entirely.
src/
├── app/ # Expo Router routes (thin screen files only)
│ ├── _layout.tsx # Root layout — navigation guards live here
│ ├── (onboarding)/ # First-launch onboarding flow
│ ├── (auth)/ # Login + register screens
│ └── (main)/ # Authenticated app (bottom tabs)
│
├── features/ # Vertical slices — one folder per domain
│ ├── auth/ # JWT auth: api, store, hooks, forms, schemas
│ ├── onboarding/ # 3-slide pager with skip/complete tracking
│ └── home/ # Main screen with theme toggle + logout
│
├── shared/ # Cross-cutting concerns (no feature deps)
│ ├── api/ # Axios client + interceptors + TanStack wrappers
│ ├── components/ # Button, TextInput, FormField, Screen, Typography…
│ ├── constants/ # Storage keys, env vars
│ ├── hooks/ # useAppReady, useColorScheme
│ ├── storage/ # Typed AsyncStorage + SecureStore wrappers
│ ├── store/ # createStore factory (Zustand)
│ └── theme/ # Colors, typography, spacing, ThemeContext
│
└── providers/
└── AppProviders.tsx # QueryClientProvider + ThemeProvider composition
App launch → useAppReady() hydrates stores from persistent storage
│
├─ onboarding not complete → (onboarding) group
├─ onboarding done, not logged in → (auth) group
└─ logged in → (main) group
Guards are reactive — completing onboarding or logging in causes the root layout to re-evaluate and navigate automatically. No router.replace() calls in screen components.
-
Create the folder
src/features/my-feature/ ├── api/ my-feature.api.ts · my-feature.types.ts ├── components/ MyFeatureScreen.tsx ├── hooks/ useMyFeature.ts ├── store/ my-feature.store.ts ├── schemas/ my-feature.schemas.ts (if forms needed) └── index.ts (barrel export — public API) -
Use shared patterns
// Store import { createStore } from '@/shared/store/factory'; export const useMyFeatureStore = createStore<State & Actions>((set) => ({ ... })); // Data fetching import { useApiQuery, useApiMutation } from '@/shared/api/hooks'; export function useMyData() { return useApiQuery(['my-data'], myFeatureApi.getData); } // Forms import { FormField } from '@/shared/components'; <FormField control={control} name="title" label="Title" />
-
Add a route (thin file — no logic)
// src/app/(main)/my-feature.tsx import { MyFeatureScreen } from '@/features/my-feature'; export default MyFeatureScreen;
-
Add a tab (optional)
// src/app/(main)/_layout.tsx — add inside <Tabs> <Tabs.Screen name="my-feature" options={{ title: 'My Feature' }} />
Rule: import only from @/features/my-feature (the barrel), never from deep paths inside another feature.
const { colors, spacing, typography, isDark, mode, setMode } = useTheme();
const styles = StyleSheet.create({
container: { backgroundColor: colors.background, padding: spacing.md },
title: { ...typography.h2, color: colors.text },
});Toggle modes: 'system' (default) · 'light' · 'dark'. Preference persisted to AsyncStorage automatically.
The auth slice ships with a dummy implementation — no backend required to run the full flow. Replace authApi.login / authApi.register / authApi.refreshTokens in src/features/auth/api/auth.api.ts with real Axios calls.
The Axios client auto-attaches Authorization: Bearer <token> to every request and handles 401 → token refresh → retry transparently via interceptors in src/shared/api/client.ts.
| Script | What it does |
|---|---|
pnpm start |
Start Expo dev server |
pnpm android |
Open on Android emulator |
pnpm ios |
Open on iOS simulator |
pnpm test |
Run Jest tests |
pnpm test:watch |
Jest in watch mode |
pnpm typecheck |
TypeScript compile check (no emit) |
pnpm lint |
ESLint with zero-warning policy |
pnpm lint:fix |
ESLint auto-fix |
pnpm format |
Prettier format all files |
pnpm format:check |
Prettier check (CI-safe) |
Push to main (or open a PR) → GitHub Actions:
- Installs deps with pnpm (cached)
- Runs
expo prebuild --platform android - Builds debug APK with Gradle (Gradle cache enabled)
- Uploads APK as a workflow artifact (7-day retention)
- Uploads to Appetize.io and posts a preview link in the job summary
Required secret: APPETIZE_API_TOKEN (from your Appetize dashboard). Appetize step is skipped gracefully if the secret is missing.
| Entity | Pattern | Example |
|---|---|---|
| Store hook | use[Feature]Store |
useProfileStore |
| Feature hook | use[Action] |
useUpdateProfile |
| Screen component | [Feature]Screen |
ProfileScreen |
| API function | camelCase verb | updateProfile() |
| Zod schema | [action]Schema |
updateProfileSchema |
| Storage key | STORAGE_KEYS.[FEATURE]_[KEY] |
STORAGE_KEYS.PROFILE_AVATAR |
Made with ❤️ by Jeffery Orazulike and the HNG projects team.