Overview
expo-ecc-starter is a batteries-included boilerplate for Expo SDK 55 + React Native 0.83 mobile apps in production. The first week of every new mobile project goes to re-wiring the same twenty things: auth + SecureStore, deep-link allowlists, theme persistence, RHF on RN, error boundaries, navigation guards, lint hooks, EAS profiles. This repo does all of it — modern, opinionated, out of the way — so day one is product code.
This is the mobile sibling of react-ecc-starter (the web version). Same Everything Claude Code (ECC) philosophy — different platform: 15 subagents, 15 slash commands, 22 skills, layered rules, AgentShield 1.4, and an EXPO_PUBLIC_* secret guard — all audited and ready for real-world use.
- Build: Expo SDK 55 + React 19.2 + React Native 0.83 + TypeScript 5.9 strict (
noUncheckedIndexedAccess+exactOptionalPropertyTypesenabled). - Routing: Expo Router 6 file-based with
(auth)/(modal)/(tabs)groups, route-levelStack.Protectedguards. - Server state: TanStack Query v5 + Zod — schema-validated, typed errors, mobile-aware retry.
- Client state: Zustand v5, auth store backed by SecureStore (never AsyncStorage).
- UI: No design-system bloat (no shadcn / NativeWind / Tamagui) —
Pressable+ theme tokens + Lucide icons, primitives owned in-repo. - Auth: Mock sign-in / sign-up +
Stack.Protected+useAuthStore, tokens stored only inexpo-secure-store; swap in a real provider when ready. - Tests: Jest 29 + React Native Testing Library + Maestro E2E (Detox fallback for bridge inspection); 60% coverage threshold.
- Security: AgentShield 1.4 on every CI build, pre-commit blocks
EXPO_PUBLIC_*secret leaks, deep links validated against an allowlist.
Why I built it
After shipping react-ecc-starter for the web, every new mobile project rebuilt the same five things: auth + token storage, light/dark/system theme, navigation guards, RHF forms over RN inputs, deep-link routing. That's 1-2 weeks of work each time — and each time risks wiring tokens to the wrong place (AsyncStorage instead of SecureStore).
The biggest difference from other boilerplates: mechanically enforced, not trusted to code review. SecureStore-only token storage is enforced via lint rules + a CI gate, not convention. Deep links are validated against a hard-coded allowlist, not a runtime check. Pre-commit blocks any EXPO_PUBLIC_* outside the whitelist — secrets never make it into a commit.
The other half is Everything Claude Code (ECC) in its mobile flavor: 15 audited subagents + 15 slash commands + 22 skills (a11y-architect for iOS/Android, react-native-build-resolver for Metro/EAS/Reanimated, e2e-runner for Maestro/Detox, security-reviewer for mobile OWASP). Open Claude Code inside the project and review, scaffold, and debug patterns are already there. Day one is product code; week one ships a screen.
Architecture
Organised around Expo Router's file-based routing with strict state / theme / native-module boundaries.
app/— Expo Router 6 file-based routes with(auth)/(modal)/(tabs)groups,_layout.tsxper group,+not-found.tsx.src/components/— Themed primitives (Button, Card, Input, Spinner) plus composites, no external design-system dependency.src/hooks/— Custom hooks (use-prefixed); React Native APIs never leak to the UI layer.src/stores/— Zustand stores; the auth store is SecureStore-backed (tokens never live in a memory dump).src/providers/— QueryClientProvider, ThemeProvider, SafeAreaProvider composed at root.src/theme/— Tokens (color, spacing, typography), light/dark/system selector, persisted via AsyncStorage (preferences only — never tokens).src/lib/— Typed HTTP, Zod schemas, deep-link validator, pure utilities (no RN deps)..claude/— 15 subagents + 15 slash commands + 22 skills (mobile ECC), audited by AgentShield 1.4..maestro/— Maestro E2E flows (smoke tests), invoked from CI and the pre-merge gate.
Pre-commit chain (Husky, no --no-verify escape): code-reviewer → typescript-reviewer → a11y-architect → e2e-runner → security-reviewer. AgentShield 1.4 runs on every CI build; Jest coverage threshold is 60%; force-push to main is blocked.
Installation
Get the project running locally in a couple of commands.
bunx degit pldkhoi/expo-ecc-starter my-app
cd my-appbun installbun init # interactive: app name, bundle id, scheme
bun dev # Metro on :8081
# bun ios | bun android | bun webcurl -Ls "https://get.maestro.mobile.dev" | bash
bun e2e:maestroUsage
Common patterns and snippets to get started fast.
// app/(auth)/_layout.tsx
import { Redirect, Stack } from 'expo-router';
import { useAuthStore } from '@/stores/auth-store';
export default function AuthLayout() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
if (!isAuthenticated) {
return <Redirect href="/sign-in" />;
}
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Protected guard={isAuthenticated}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="(modal)" options={{ presentation: 'modal' }} />
</Stack.Protected>
</Stack>
);
}// src/stores/auth-store.ts
import { create } from 'zustand';
import * as SecureStore from 'expo-secure-store';
const TOKEN_KEY = 'auth.token';
interface AuthState {
token: string | null;
isAuthenticated: boolean;
signIn: (token: string) => Promise<void>;
signOut: () => Promise<void>;
hydrate: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set) => ({
token: null,
isAuthenticated: false,
signIn: async (token) => {
await SecureStore.setItemAsync(TOKEN_KEY, token, {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
set({ token, isAuthenticated: true });
},
signOut: async () => {
await SecureStore.deleteItemAsync(TOKEN_KEY);
set({ token: null, isAuthenticated: false });
},
hydrate: async () => {
const token = await SecureStore.getItemAsync(TOKEN_KEY);
set({ token, isAuthenticated: token !== null });
},
}));// src/components/themed-button.tsx
import { Pressable, Text, StyleSheet } from 'react-native';
import { useTheme } from '@/theme/use-theme';
interface ThemedButtonProps {
label: string;
onPress: () => void;
variant?: 'primary' | 'secondary';
}
export function ThemedButton({ label, onPress, variant = 'primary' }: ThemedButtonProps) {
const { tokens } = useTheme();
return (
<Pressable
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={label}
style={({ pressed }) => [
styles.base,
{
backgroundColor:
variant === 'primary' ? tokens.color.brand : tokens.color.surfaceMuted,
opacity: pressed ? 0.85 : 1,
minHeight: 48,
},
]}
>
<Text style={{ color: tokens.color.onBrand, fontSize: tokens.typography.body }}>
{label}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
base: {
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
});Roadmap
- Expand
.maestro/flows: onboarding carousel, paywall, deep-link settings, biometric unlock. - Add sample screens: 3-step onboarding, RevenueCat-ready paywall, settings with deep-link sharing.
- More ECC subagents:
release-orchestratorfor auto-changelog + tag,eas-build-resolverfocused on EAS Build / Reanimated failure modes. - EAS Build profile docs (development / preview / production) + a
bun releasescript. - Expo SDK 55 → SDK 56 migration guide as soon as the RC stabilises, with auto-fix scripts for common breaking changes.