Claude-skill-registry expo-app-setup
Guidance for building, refactoring, and debugging Expo + React Native apps (including Expo Router). Use when wiring screens/layouts, navigation (tab/stack) scaffolding with `_layout.tsx` per group, avoiding unwanted redirects in `app/index.tsx`, theming, data fetching with React Query/fetch, Expo module usage, offline handling, and running local Expo tooling (install/start/lint).
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/expo-app-setup" ~/.claude/skills/majiayu000-claude-skill-registry-expo-app-setup && rm -rf "$T"
manifest:
skills/data/expo-app-setup/SKILL.mdsource content
Expo App Setup
Overview
Actionable playbook for adding features, managing boilerplate code, implementing modern navigation patterns using Expo Router, state management, fixing bugs, and shipping UI and logic in cross-platform mobile applications using Expo and React Native projects. Repo defaults:
src/ app root with @ alias (see tsconfig.json), Expo Router in src/app, and React Query provider in src/app/_layout.tsx.
Navigation guardrails (avoid common mistakes)
- Every stack group needs its own
: root_layout.tsx
,app/_layout.tsx
, and aapp/(tabs)/_layout.tsx
inside each tab folder._layout.tsx - Root
should render a landing screen; only addapp/index.tsx
when explicitly requested.Redirect - When adding a tab: create
, addapp/(tabs)/<tab>
with a_layout.tsx
(headers off unless needed), addStack
, then register the tab inindex.tsx
.app/(tabs)/_layout.tsx - Keep imports ordered (React/React Native, third-party, then
aliases) and follow repo style (2-space indent, double quotes, semicolons).@/
Canonical scaffold:
src/app/ _layout.tsx // root Stack -> (tabs) index.tsx // landing screen (no redirect unless requested) (tabs)/ _layout.tsx // Tabs navigator home/ _layout.tsx // Stack for Home tab index.tsx profile/ _layout.tsx // Stack for Profile tab index.tsx
Minimal layout snippets:
// src/app/_layout.tsx // Root layout with React Query provider import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Stack } from "expo-router"; const queryClient = new QueryClient(); export default function RootLayout() { return ( <QueryClientProvider client={queryClient}> <Stack screenOptions={{ headerShown: false }}> <Stack.Screen name="(tabs)" /> </Stack> </QueryClientProvider> ); }
// app/(tabs)/_layout.tsx import { Tabs } from "expo-router"; export default function TabLayout() { return ( <Tabs> <Tabs.Screen name="home" options={{ title: "Home", headerShown: false }} /> <Tabs.Screen name="profile" options={{ title: "Profile", headerShown: false }} /> </Tabs> ); }
// app/(tabs)/home/_layout.tsx import { Stack } from "expo-router"; export default function HomeStack() { return <Stack screenOptions={{ headerShown: false }} />; }
// app/index.tsx import { View, Text, StyleSheet } from "react-native"; export default function IndexScreen() { return ( <View style={styles.container}> <Text style={styles.title}>MovieKnight</Text> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: "center", justifyContent: "center" }, title: { fontSize: 24, fontWeight: "600" }, });
Core workflow
- Clarify the task: screen/flow/bug, target platforms (iOS/Android/web), offline/low-connectivity expectations.
- Locate context: relevant route file, component, provider, and shared utilities/theme.
- Implement using the patterns below; reuse shared components and helpers before adding new ones.
- Verify: lint, run the flow on target platforms, and check loading/error/offline paths.
- Identify routing system (Expo Router vs plain navigation). Mirror existing patterns and folder structure.
Quick start
- For package manager of choice, use
orbun
.bunx - Install dependencies with project's tool (
or equivalent).bunx expo install - To start the development server locally, use
orbunx expo start
.bunx expo start -c - Lint before shipping (
or the project’s lint script).bunx expo lint
Patterns
- Images/media: Build URLs via helpers (e.g.,
); handle null paths; usemakeImageUrl
.expo-image - Platform concerns: Use
for variant styling/behavior. Avoid Node-only modules; keep code Expo-compatible.Platform.select - Animations: Keep animations within Reanimated limits; wrap UI in performant components when needed.
- State and props: Type all props; use
instead ofPropsWithChildren
where linted. Keep derived state memoized; avoid heavy work in render.ReactNode - Accessibility: Add labels on non-obvious pressables, ensure comfortable hit areas, and keep touch targets platform-appropriate.
Components to reuse (ui patterns)
- Posters & fallbacks:
usesMoviePosterItem
+expo-image
with a gray placeholder; keep sizes 140x210, radius 12,makeImageUrl
andcontentFit="cover"
.transition - Horizontal rows:
renders a titled row withMovieRow
andScrollView
toLink
; uses gesture-handler/(tabs)/(home)/[id]
for “See all” and blue accentPressable
.#007AFF - Gallery:
for detail screens; horizontal posters withPosterGallery
andChevronRightIcon
guard.makeImageUrl - Screen states:
for loading/error withScreenStateActivityIndicator
and centered copy; prefer this instead of ad-hoc spinners.#007AFF - Hero blocks:
(blurred backdrop viaDetailsHero
, overlay,expo-image
+MoviePosterHero
+GenreChips
), rounded cards and layered backgrounds.MovieFactRow - Conventions: 2-space indent, double quotes,
imports,@/
for media,expo-image
/FlatList
with pull-to-refresh, and link-driven nav (ScrollViewexpo-router
) for items.Link
Instructions
1. Project Setup and Navigation
Basic setup
// src/app/_layout.tsx // Create root layout for the app with React Query provider import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Stack } from "expo-router"; const queryClient = new QueryClient(); export default function RootLayout() { return ( <QueryClientProvider client={queryClient}> <Stack> <Stack.Screen name="(tabs)" options={{ headerShown: false }} /> </Stack> </QueryClientProvider> ); }
Create a tab navigator
// src/app/(tabs)/_layout.tsx // Tab navigator using NativeTabs (expo-router/unstable-native-tabs) import Ionicons from "@expo/vector-icons/Ionicons"; import { Icon, Label, NativeTabs, VectorIcon, } from "expo-router/unstable-native-tabs"; import { Platform } from "react-native"; export default function TabLayout() { return ( <NativeTabs backgroundColor={Platform.select({ android: "#FFFFFF" })} minimizeBehavior="onScrollDown" iconColor={Platform.select({ android: { default: "#0f172a", selected: "#2563eb" }, })} labelVisibilityMode="labeled" labelStyle={Platform.select({ android: { default: { color: "#0f172a" }, selected: { color: "#2563eb" }, }, })} indicatorColor={Platform.select({ android: "#e0e7ff" })} > <NativeTabs.Trigger name="(home)"> {Platform.select({ ios: <Icon sf={{ default: "film", selected: "film.fill" }} />, android: ( <Icon src={<VectorIcon family={Ionicons} name="film-outline" />} /> ), })} <Label>Home</Label> </NativeTabs.Trigger> <NativeTabs.Trigger name="settings"> {Platform.select({ ios: ( <Icon sf={{ default: "gear.circle", selected: "gear.circle.fill" }} /> ), android: ( <Icon src={<VectorIcon family={Ionicons} name="settings-outline" />} /> ), })} <Label>Settings</Label> </NativeTabs.Trigger> <NativeTabs.Trigger name="search" role="search"> {Platform.select({ ios: ( <Icon sf={{ default: "magnifyingglass", selected: "magnifyingglass" }} /> ), android: ( <Icon src={<VectorIcon family={Ionicons} name="search" />} /> ), })} <Label>Search</Label> </NativeTabs.Trigger> </NativeTabs> ); }
Boilerplate screens
// src/app/(tabs)/(home)/index.tsx // Create a home screen import { Link } from "expo-router"; import { View } from "react-native"; export default function Home() { return ( <View style={{ flex: 1, justifyContent: "center", alignItems: "center", }} > <Link href="/[id]/1">Go to details</Link> </View> ); }
// src/app/(tabs)/(home)/[id].tsx // Create a details screen import { useLocalSearchParams } from "expo-router"; import { Text, View } from "react-native"; export default function Details() { const { id } = useLocalSearchParams<{ id: string }>(); return ( <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}> <Text>Details for {id}</Text> </View> ); }
// src/app/(tabs)/(home)/_layout.tsx // Create a layout for the home screen import { Stack } from "expo-router"; import { Platform } from "react-native"; function getIOSVersion(): number { if (Platform.OS !== "ios") return 0; return parseInt(Platform.Version as string, 10); } function isIOS26OrLater(): boolean { return getIOSVersion() >= 26; } export default function HomeLayout() { return ( <Stack> <Stack.Screen name="index" options={{ title: "Home", headerLargeTitle: true, headerTransparent: Platform.OS === "ios", headerBlurEffect: isIOS26OrLater() ? undefined : "regular", }} /> <Stack.Screen name="[id]" options={{ title: "Movie Details", headerTransparent: Platform.OS === "ios", headerBlurEffect: isIOS26OrLater() ? undefined : "regular", }} /> </Stack> ); }
// src/app/(tabs)/settings/index.tsx // Create a settings screen import { Text, View } from "react-native"; export default function Settings() { return ( <View style={{ flex: 1, justifyContent: "center", alignItems: "center", }} > <Text>Edit app/(tabs)/settings.tsx to edit this screen.</Text> </View> ); }
// src/app/(tabs)/settings/_layout.tsx // Create a layout for the settings screen import { Stack } from "expo-router"; export default function SettingsLayout() { return ( <Stack> <Stack.Screen name="index" options={{ title: "Settings", headerLargeTitle: true, headerTransparent: true, }} /> </Stack> ); }
// src/app/(tabs)/search/index.tsx // Create a search screen import { Text, View } from "react-native"; export default function Search() { return ( <View style={{ flex: 1, justifyContent: "center", alignItems: "center", }} > <Text>Edit app/(tabs)/search/index.tsx to edit this screen.</Text> </View> ); }
// src/app/(tabs)/search/_layout.tsx // Create a layout for the search screen import { Stack } from "expo-router"; export default function SearchLayout() { return ( <Stack> <Stack.Screen name="index" options={{ title: "Search", headerSearchBarOptions: { placeholder: "Search ...", placement: "automatic", onChangeText: () => {}, }, }} /> </Stack> ); }
2. API integration (TMDB example in src/services
)
src/servicesAdd config and API calls in
src/services with env-driven API key and @/ imports.
// src/services/config.ts export const TMDB_API_BASE_URL = "https://api.themoviedb.org/3"; export const TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p"; export const TMDB_POSTER_SIZE = "w500"; export const TMDB_API_KEY = process.env.EXPO_PUBLIC_TMDB_API_KEY ?? ""; export const makeImageUrl = ( path?: string | null, size = TMDB_POSTER_SIZE ): string | null => { if (!path) return null; return `${TMDB_IMAGE_BASE_URL}/${size}${path}`; };
// src/services/movies.ts import { TMDB_API_BASE_URL, TMDB_API_KEY } from "@/services/config"; export type Movie = { id: number; title: string; overview: string; poster_path: string | null; backdrop_path: string | null; release_date: string; vote_average: number; }; type PopularMoviesResponse = { results: Movie[] }; const ensureApiKey = () => { if (!TMDB_API_KEY) { throw new Error( "TMDB API key missing. Set EXPO_PUBLIC_TMDB_API_KEY to fetch movies." ); } }; export const fetchPopularMovies = async (page = 1): Promise<Movie[]> => { ensureApiKey(); const response = await fetch( `${TMDB_API_BASE_URL}/movie/popular?language=en-US&page=${page}&api_key=${TMDB_API_KEY}` ); if (!response.ok) { throw new Error(`TMDB popular movies request failed: ${response.status}`); } const data = (await response.json()) as PopularMoviesResponse; return data.results; }; export const popularMoviesQuery = (page = 1) => ({ queryKey: ["popularMovies", page], queryFn: () => fetchPopularMovies(page), });
3. Project structure
Use a
src/ directory for everything, including the Expo Router app/ folder (routes live at src/app). Update tsconfig.json paths to @/* -> ./src/* and set the Expo Router app root (e.g., EXPO_ROUTER_APP_ROOT=src/app in env or expo-router plugin config) so routing resolves correctly.
├── assets/ └── src/ ├── app/ │ ├── (tabs)/ │ └── _layout.tsx ├── components/ ├── services/ ├── providers/ ├── constants/ ├── utils/ ├── hooks/ └── types/ ├── app.json/app.config.ts ├── eas.json └── package.json
Best practices
DO
- Use functional components with React Hooks
- Implement proper error handling and loading states
- Use React Query for data fetching and state management
- Leverage Expo Router for routing
- Optimize list rendering with
FlatList - Handle platform-specific code elegantly
- Use TypeScript for type safety; keep shared domain types in a common
module and keep component-prop types next to the componenttypes/ - Test on both iOS and Android platforms
- Implement proper memory management
DON'T
- Use inline styles excessively (use StyleSheet)
- Make API calls without error handling
- Store sensitive data in plain text
- Ignore platform differences
- Create large monolithic components
- Use index as key in lists
- Make synchronous operations
- Ignore battery optimization
- Deploy without testing on real devices
- Forget to unsubscribe from listeners
Verification
- Pre-flight: confirm
exists at root, tab container, and each tab folder; ensure_layout.tsx
matches requested behavior (screen by default, redirect only when asked); align imports; run lint.app/index.tsx - Lint before shipping (
or the project’s lint script). Fix type errors.bun run lint - Run the affected flow on target platforms. Test offline/poor network if you touched requests.
- Check for regressions in navigation (back/replace), loading/error states, and skeletons/placeholders.
Common commands (adapt to project scripts)
- Install:
/bunx expo install
(match repo)npx expo install - Start:
(orbunx expo start
)npx expo start --ios/--android/--web - Lint:
(orbunx expo lint
)npx expo lint
References
- Router scaffolding checklist:
.references/router-scaffolding.md