| name | expo-app-setup |
| description | 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). |
| license | MIT |
| compatibility | Expo CNG project with Expo Router + TypeScript strict; needs Expo CLI/bun/npm for installs and linting; no extra system packages beyond tooling defaults. |
| metadata | [object Object] |
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
_layout.tsx: rootapp/_layout.tsx,app/(tabs)/_layout.tsx, and a_layout.tsxinside each tab folder. - Root
app/index.tsxshould render a landing screen; only addRedirectwhen explicitly requested. - When adding a tab: create
app/(tabs)/<tab>, add_layout.tsxwith aStack(headers off unless needed), addindex.tsx, then register the tab inapp/(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
bunorbunx. - Install dependencies with project's tool (
bunx expo installor equivalent). - To start the development server locally, use
bunx expo startorbunx expo start -c. - Lint before shipping (
bunx expo lintor the project’s lint script).
Patterns
- Images/media: Build URLs via helpers (e.g.,
makeImageUrl); handle null paths; useexpo-image. - Platform concerns: Use
Platform.selectfor variant styling/behavior. Avoid Node-only modules; keep code Expo-compatible. - Animations: Keep animations within Reanimated limits; wrap UI in performant components when needed.
- State and props: Type all props; use
PropsWithChildreninstead ofReactNodewhere linted. Keep derived state memoized; avoid heavy work in render. - Accessibility: Add labels on non-obvious pressables, ensure comfortable hit areas, and keep touch targets platform-appropriate.
Components to reuse (ui patterns)
- Posters & fallbacks:
MoviePosterItemusesexpo-image+makeImageUrlwith a gray placeholder; keep sizes 140x210, radius 12,contentFit="cover"andtransition. - Horizontal rows:
MovieRowrenders a titled row withScrollViewandLinkto/(tabs)/(home)/[id]; uses gesture-handlerPressablefor “See all” and blue accent#007AFF. - Gallery:
PosterGalleryfor detail screens; horizontal posters withChevronRightIconandmakeImageUrlguard. - Screen states:
ScreenStatefor loading/error withActivityIndicator#007AFFand centered copy; prefer this instead of ad-hoc spinners. - Hero blocks:
DetailsHero(blurred backdrop viaexpo-image, overlay,MoviePosterHero+GenreChips+MovieFactRow), rounded cards and layered backgrounds. - Conventions: 2-space indent, double quotes,
@/imports,expo-imagefor media,FlatList/ScrollViewwith pull-to-refresh, and link-driven nav (expo-routerLink) for items.
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)
Add 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
types/module and keep component-prop types next to the component - 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
_layout.tsxexists at root, tab container, and each tab folder; ensureapp/index.tsxmatches requested behavior (screen by default, redirect only when asked); align imports; run lint. - Lint before shipping (
bun run lintor the project’s lint script). Fix type errors. - 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/npx expo install(match repo) - Start:
bunx expo start(ornpx expo start --ios/--android/--web) - Lint:
bunx expo lint(ornpx expo lint)
References
- Router scaffolding checklist:
references/router-scaffolding.md.