mobile/react-native-project-starter/SKILL.md
--- name: react-native-project-starter description: > Scaffold a production-ready React Native 0.76+ app with Expo SDK 52+, Expo Router for file-based navigation, Zustand for state management, TypeScript, EAS Build, platform-specific code, and AsyncStorage. category: mobile agent-type: coding compatibility: Node.js >= 20.x, npm >= 10.x, Expo CLI, EAS CLI, iOS: Xcode 16+, Android: Android Studio with SDK 35+ --- # React Native Project Starter > Scaffold a production-ready React Native 0.7
npx skillsauth add achreftlili/deep-dev-skills mobile/react-native-project-starterInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Scaffold a production-ready React Native 0.76+ app with Expo SDK 52+, Expo Router for file-based navigation, Zustand for state management, TypeScript, EAS Build, platform-specific code, and AsyncStorage.
npx expo)npm install -g eas-cli)npx create-expo-app@latest <project-name> --template tabs
cd <project-name>
# Install core dependencies
npx expo install expo-router expo-linking expo-constants expo-status-bar
npx expo install @react-native-async-storage/async-storage
npx expo install expo-secure-store
npx expo install expo-splash-screen expo-image
# State management
npm install zustand
# HTTP client
npm install axios
# Form handling
npm install react-hook-form zod @hookform/resolvers
# Dev dependencies
npm install -D @types/react @types/react-native
app/
_layout.tsx # Root layout — providers, fonts, splash screen
(tabs)/
_layout.tsx # Tab navigator layout
index.tsx # Home tab — @page "/"
profile.tsx # Profile tab
(auth)/
_layout.tsx # Auth flow layout (no tabs)
login.tsx # Login screen
register.tsx # Register screen
users/
[id].tsx # Dynamic route — /users/:id
index.tsx # User list — /users
+not-found.tsx # 404 screen
+html.tsx # Custom HTML wrapper (web only)
components/
ui/
Button.tsx # Reusable UI components
Input.tsx
Card.tsx
LoadingSpinner.tsx
UserCard.tsx # Domain-specific components
UserForm.tsx
hooks/
useAuth.ts # Authentication hook
useUsers.ts # Data fetching hook
stores/
authStore.ts # Zustand auth store
userStore.ts # Zustand user store
services/
api.ts # Axios instance with interceptors
auth.ts # Auth API calls
users.ts # User API calls
lib/
storage.ts # AsyncStorage wrapper
constants.ts # App constants
types.ts # Shared TypeScript types
assets/
images/
fonts/
app/ defines navigation_layout.tsx) wrap child routes and define navigation structure (tabs, stacks, drawers)(group) create route groups without affecting the URL[param].tsx syntax, accessed via useLocalSearchParams().ios.tsx / .android.tsx suffixes or Platform.select()expo-secure-store for tokens/secrets, AsyncStorage for non-sensitive dataservices/ — components never call fetch/axios directlyany typesexpo-image instead of <Image> from React Native for better performance and cachingapp/_layout.tsximport { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { useEffect } from 'react';
import * as SplashScreen from 'expo-splash-screen';
import { useFonts } from 'expo-font';
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [fontsLoaded] = useFonts({
'Inter-Regular': require('../assets/fonts/Inter-Regular.ttf'),
'Inter-Bold': require('../assets/fonts/Inter-Bold.ttf'),
});
useEffect(() => {
if (fontsLoaded) {
SplashScreen.hideAsync();
}
}, [fontsLoaded]);
if (!fontsLoaded) return null;
return (
<>
<StatusBar style="auto" />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="(auth)" />
<Stack.Screen name="users/[id]" options={{ headerShown: true, title: 'User' }} />
<Stack.Screen name="+not-found" />
</Stack>
</>
);
}
app/(tabs)/_layout.tsximport { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#007AFF',
headerShown: true,
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
app/users/index.tsximport { View, FlatList, StyleSheet, ActivityIndicator, Pressable } from 'react-native';
import { Link } from 'expo-router';
import { useUsers } from '../../hooks/useUsers';
import { UserCard } from '../../components/UserCard';
export default function UsersScreen() {
const { users, isLoading, error, refresh } = useUsers();
if (isLoading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" />
</View>
);
}
return (
<FlatList
data={users}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<Link href={`/users/${item.id}`} asChild>
<Pressable>
<UserCard user={item} />
</Pressable>
</Link>
)}
onRefresh={refresh}
refreshing={isLoading}
contentContainerStyle={styles.list}
/>
);
}
const styles = StyleSheet.create({
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
list: { padding: 16, gap: 12 },
});
app/users/[id].tsximport { View, Text, StyleSheet } from 'react-native';
import { useLocalSearchParams, Stack } from 'expo-router';
import { useEffect, useState } from 'react';
import { userService } from '../../services/users';
import type { User } from '../../lib/types';
export default function UserDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
userService.getById(id).then(setUser);
}, [id]);
if (!user) return null;
return (
<>
<Stack.Screen options={{ title: user.name }} />
<View style={styles.container}>
<Text style={styles.name}>{user.name}</Text>
<Text style={styles.email}>{user.email}</Text>
</View>
</>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16 },
name: { fontSize: 24, fontWeight: 'bold' },
email: { fontSize: 16, color: '#666', marginTop: 4 },
});
stores/authStore.tsimport { create } from 'zustand';
import * as SecureStore from 'expo-secure-store';
import { authService } from '../services/auth';
interface AuthState {
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
loadToken: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set) => ({
token: null,
isAuthenticated: false,
isLoading: true,
login: async (email, password) => {
const { token } = await authService.login(email, password);
await SecureStore.setItemAsync('auth_token', token);
set({ token, isAuthenticated: true });
},
logout: async () => {
await SecureStore.deleteItemAsync('auth_token');
set({ token: null, isAuthenticated: false });
},
loadToken: async () => {
const token = await SecureStore.getItemAsync('auth_token');
set({ token, isAuthenticated: !!token, isLoading: false });
},
}));
services/api.tsimport axios from 'axios';
import * as SecureStore from 'expo-secure-store';
import Constants from 'expo-constants';
const API_URL = Constants.expoConfig?.extra?.apiUrl ?? 'http://localhost:8080';
export const api = axios.create({
baseURL: `${API_URL}/api`,
timeout: 10000,
headers: { 'Content-Type': 'application/json' },
});
// Attach auth token to every request
api.interceptors.request.use(async (config) => {
const token = await SecureStore.getItemAsync('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle 401 globally
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
await SecureStore.deleteItemAsync('auth_token');
// Navigate to login — handled by auth state listener
}
return Promise.reject(error);
}
);
services/users.tsimport { api } from './api';
import type { User, CreateUserRequest } from '../lib/types';
export const userService = {
getAll: async (): Promise<User[]> => {
const { data } = await api.get('/users');
return data.data;
},
getById: async (id: string): Promise<User> => {
const { data } = await api.get(`/users/${id}`);
return data.data;
},
create: async (body: CreateUserRequest): Promise<User> => {
const { data } = await api.post('/users', body);
return data.data;
},
update: async (id: string, body: Partial<CreateUserRequest>): Promise<User> => {
const { data } = await api.put(`/users/${id}`, body);
return data.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/users/${id}`);
},
};
hooks/useUsers.tsimport { useEffect, useState, useCallback } from 'react';
import { userService } from '../services/users';
import type { User } from '../lib/types';
export function useUsers() {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetch = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await userService.getAll();
setUsers(data);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to fetch users');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetch();
}, [fetch]);
return { users, isLoading, error, refresh: fetch };
}
// components/ui/Button.tsx — shared
import { Platform, Pressable, Text, StyleSheet } from 'react-native';
interface ButtonProps {
title: string;
onPress: () => void;
variant?: 'primary' | 'secondary';
}
export function Button({ title, onPress, variant = 'primary' }: ButtonProps) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [
styles.button,
variant === 'primary' ? styles.primary : styles.secondary,
pressed && styles.pressed,
// Platform-specific shadow
Platform.select({
ios: styles.iosShadow,
android: { elevation: 4 },
}),
]}
>
<Text style={[styles.text, variant === 'secondary' && styles.secondaryText]}>
{title}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
button: { paddingVertical: 12, paddingHorizontal: 24, borderRadius: 8, alignItems: 'center' },
primary: { backgroundColor: '#007AFF' },
secondary: { backgroundColor: 'transparent', borderWidth: 1, borderColor: '#007AFF' },
pressed: { opacity: 0.7 },
text: { color: '#fff', fontSize: 16, fontWeight: '600' },
secondaryText: { color: '#007AFF' },
iosShadow: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.15, shadowRadius: 4 },
});
lib/types.tsexport interface User {
id: string;
email: string;
name: string;
createdAt: string;
}
export interface CreateUserRequest {
email: string;
name: string;
password: string;
}
export interface AuthResponse {
token: string;
user: User;
}
lib/storage.tsimport AsyncStorage from '@react-native-async-storage/async-storage';
export const storage = {
get: async <T>(key: string): Promise<T | null> => {
const value = await AsyncStorage.getItem(key);
return value ? JSON.parse(value) : null;
},
set: async <T>(key: string, value: T): Promise<void> => {
await AsyncStorage.setItem(key, JSON.stringify(value));
},
remove: async (key: string): Promise<void> => {
await AsyncStorage.removeItem(key);
},
clear: async (): Promise<void> => {
await AsyncStorage.clear();
},
};
# Start development server
npx expo start
# Run on specific platform
npx expo run:ios
npx expo run:android
# Start with tunnel (for physical device on different network)
npx expo start --tunnel
# Clear cache
npx expo start --clear
# Install Expo-compatible package
npx expo install <package-name>
# EAS Build
eas build --platform ios
eas build --platform android
eas build --platform all
# EAS Submit to stores
eas submit --platform ios
eas submit --platform android
# EAS Update (OTA updates)
eas update --branch production --message "Bug fix"
# Prebuild native projects (if ejecting from managed workflow)
npx expo prebuild
# Lint
npx expo lint
# TypeScript check
npx tsc --noEmit
<Link> for declarative navigation, router.push() / router.replace() for imperative.@tanstack/react-query alongside Zustand.(auth) and (tabs) with a root layout that redirects based on useAuthStore().isAuthenticated. Expo Router supports redirect in layout components.expo-secure-store for tokens and secrets (uses Keychain on iOS, Keystore on Android). AsyncStorage for non-sensitive data (preferences, cached data).eas build. Configure in eas.json with profiles (development, preview, production). Development builds include dev tools, production builds are store-ready.eas update pushes JS bundle updates without app store review. Configure update channels per build profile..ios.tsx / .android.tsx file suffixes for large platform differences. Use Platform.OS or Platform.select() for small differences.jest with @testing-library/react-native for component tests. Use detox for E2E tests on simulators/emulators.@react-native-firebase/app + feature packages (e.g., @react-native-firebase/auth, @react-native-firebase/firestore).testing
Set up Vitest 2.x with TypeScript for unit and component testing using test/describe/it, vi.fn/vi.mock/vi.spyOn, component testing with Testing Library, coverage (v8/istanbul), workspace config, and snapshot testing.
testing
Set up pytest 8.x with Python for unit and integration testing using fixtures (scope, autouse, parametrize), async tests (pytest-asyncio), mocking (unittest.mock, pytest-mock), coverage (pytest-cov), conftest.py patterns, and markers.
testing
Set up Playwright 1.49+ with TypeScript for E2E testing using page object model, fixtures, test.describe/test blocks, assertions, selectors, network mocking, CI configuration, and trace viewer.
testing
Set up Jest 30+ with TypeScript for unit tests, integration tests, mocking (jest.fn, jest.mock, jest.spyOn), coverage configuration, custom matchers, snapshot testing, and setup/teardown patterns.