mobile-navigation-expo-router
File-based routing and navigation for Expo/React Native
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/mobile-navigation-expo-router/skills/mobile-navigation-expo-router
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
git clone https://github.com/agents-inc/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Expo Router Patterns
Quick Guide: File-based routing for React Native and web. Files in
app/become routes automatically. Use_layout.tsxfor navigation structure (Stack, Tabs), groups(name)/for URL-invisible organization,[param]for dynamic segments. SDK 53+: useStack.Protectedwith aguardprop for authentication. EnabletypedRoutesfor compile-time route safety. API routes use+api.tssuffix.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST define navigation structure in _layout.tsx files -- screens without a layout parent default to a basic Stack)
(You MUST use Stack.Protected with guard prop for authentication flows in SDK 53+ -- NOT imperative redirects in useEffect)
(You MUST use useLocalSearchParams for route params in screens -- useGlobalSearchParams causes unnecessary re-renders on unfocused screens)
(You MUST enable typedRoutes in app.json experiments for compile-time route validation -- catches invalid navigation at build time)
</critical_requirements>
Auto-detection: expo-router, Expo Router, file-based routing, _layout.tsx, Stack.Screen, Tabs.Screen, useRouter, useLocalSearchParams, useSegments, usePathname, Link href, router.push, router.replace, router.dismiss, router.dismissTo, +api.ts, +not-found, Stack.Protected, generateStaticParams, expo-router/head, Slot, Redirect, useFocusEffect, NativeTabs, headless tabs, TabSlot, TabTrigger
When to use:
- Setting up file-based navigation in an Expo app
- Implementing authentication flows with route protection
- Creating tab, stack, or modal navigation layouts
- Building API routes for server-side logic
- Configuring typed routes for compile-time safety
- Adding deep linking and static rendering for web
Key patterns covered:
- File convention:
_layout.tsx,[param],[...slug],(group)/,+api.ts,+not-found.tsx - Layout navigators: Stack, Tabs, headless tabs, native tabs
- Authentication:
Stack.Protectedguard pattern (SDK 53+), redirect pattern (SDK 52) - Navigation hooks:
useRouter,useLocalSearchParams,useSegments,usePathname - API routes with standard Request/Response
- Typed routes with auto-generated TypeScript definitions
- Modal routes, shared routes between tabs, nested navigation
When NOT to use:
- Apps that need fully custom native navigation controllers beyond what React Navigation provides
- Simple single-screen apps with no navigation
- Web-only projects where a web-native router is more appropriate
Detailed Resources:
- examples/core.md - Directory structure, layouts, tabs, navigation hooks, typed routes, modals
- examples/auth.md - Stack.Protected pattern, SessionProvider, legacy redirect pattern
- examples/api-routes.md - API route handlers, error handling, deployment
- examples/web.md - Static rendering, Head metadata, root HTML
- reference.md - Decision frameworks, version compatibility
<decision_framework>
Decision Frameworks
Expo Router provides multiple navigation patterns. The key decisions:
- Route type -- static, dynamic, catch-all, grouped, API? See reference.md for the full route type decision tree.
- Navigation method -- declarative
<Link>vs imperativerouter.push/replace/dismiss? See reference.md for the navigation method decision tree. - Layout navigator -- Stack, Tabs, NativeTabs, headless tabs, or
<Slot />? See reference.md for the layout navigator selection guide. - Hook choice --
useLocalSearchParamsvsuseGlobalSearchParams,useRoutervs<Link>,useFocusEffectvsuseEffect? See reference.md for the hook selection table.
Quick rules:
- Prefer
<Link>for static navigation in UI,router.pushfor programmatic navigation in event handlers - Always use
useLocalSearchParamsunless you specifically need background screen updates - Use
useFocusEffectinstead ofuseEffectwhen data should refresh on screen focus
</decision_framework>
<red_flags>
RED FLAGS
High Priority Issues:
- Using
useGlobalSearchParamswhenuseLocalSearchParamsworks -- global causes re-renders on ALL route changes, even when screen is in background; use local for screen-specific params - Imperative redirects in useEffect for auth (SDK 53+) -- use
Stack.Protectedwithguardprop instead; it's declarative, handles edge cases, and integrates with deep linking correctly - Missing
_layout.tsxin route groups -- without a layout, the default Stack has default headers and no control over transitions; always define layouts explicitly - Storing secrets in API route responses without authentication -- API routes are public endpoints; validate authentication tokens before returning sensitive data
Medium Priority Issues:
nameprop mismatch in layout screens --Stack.Screen name="tabs"does not match directory(tabs)/; must bename="(tabs)"exactly- Not using
presentation: "modal"in parent layout -- configuring modal in the modal file's own layout does nothing; modals must be configured in the parent navigator - Calling
router.replacein initial render -- causes navigation before the navigator is ready; useRedirectcomponent oruseFocusEffectinstead
Gotchas & Edge Cases:
- Deep links to protected routes:
Stack.Protectedredirects to the first unprotected screen -- deep link target is lost unless you store and replay it after auth - Catch-all
[...slug]params: Always an array, butuseLocalSearchParamsmay return a string if only one segment; always normalize withArray.isArray(slug) ? slug : [slug] - Tab groups reset on tab switch: By default, switching tabs resets the tab's stack; use
backBehavior: "history"in Tabs layout to preserve stack per tab - Android 5-tab limit: Material Design constrains bottom tabs to 5; native tabs enforce this
+not-found.tsxonly catches at its directory level -- a+not-found.tsxinapp/won't catch 404s insideapp/docs/; each directory needs its own if required- Static rendering
generateStaticParamsruns in Node.js -- no access to React Native APIs, browser APIs, or native modules - API route limitation: No dynamic imports, no platform-specific extensions (
+api.web.tsis invalid), bundles to CommonJS - Typed routes are git-ignored -- CI pipelines fail type checks unless types are regenerated with
npx expo customize tsconfig.json - Route files require
export default-- Expo Router discovers screens via default exports; this overrides project "named exports only" conventions for files inapp/
</red_flags>
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md
(You MUST define navigation structure in _layout.tsx files -- screens without a layout parent default to a basic Stack)
(You MUST use Stack.Protected with guard prop for authentication flows in SDK 53+ -- NOT imperative redirects in useEffect)
(You MUST use useLocalSearchParams for route params in screens -- useGlobalSearchParams causes unnecessary re-renders on unfocused screens)
(You MUST enable typedRoutes in app.json experiments for compile-time route validation -- catches invalid navigation at build time)
Failure to follow these rules will cause navigation bugs, auth bypasses, unnecessary re-renders, and runtime routing errors that typed routes would catch at compile time.
</critical_reminders>
Files (skills)
-
examples
-
api-routes.md 4 KB
# API Routes > Server-side endpoints with +api.ts files. See [SKILL.md](../SKILL.md) for decisions, [core.md](core.md) for navigation basics. --- ## Setup API routes require server output mode in app.json: ```json { "expo": { "web": { "output": "server" }, "plugins": [ [ "expo-router", { "origin": "https://api.example.com/" } ] ] } } ``` The `origin` property tells native apps where to send API requests. Without it, native API calls have no server to target. --- ## Basic CRUD Routes ```typescript // app/api/users+api.ts export async function GET(request: Request) { const url = new URL(request.url); const page = url.searchParams.get("page") ?? "1"; const limit = url.searchParams.get("limit") ?? "20"; const users = await db.users.findMany({ skip: (Number(page) - 1) * Number(limit), take: Number(limit), }); return Response.json(users); } export async function POST(request: Request) { const body = await request.json(); const user = await db.users.create({ data: body, }); return Response.json(user, { status: 201 }); } ``` ```typescript // app/api/users/[id]+api.ts -- Dynamic API route export async function GET(request: Request, { id }: { id: string }) { const user = await db.users.findUnique({ where: { id } }); if (!user) { return new Response("User not found", { status: 404 }); } return Response.json(user); } export async function PUT(request: Request, { id }: { id: string }) { const body = await request.json(); const user = await db.users.update({ where: { id }, data: body, }); return Response.json(user); } export async function DELETE(_request: Request, { id }: { id: string }) { await db.users.delete({ where: { id } }); return new Response(null, { status: 204 }); } ``` --- ## Error Handling with StatusError ```typescript // app/api/posts+api.ts import { StatusError } from "expo-server"; export async function GET(request: Request) { const url = new URL(request.url); const postId = url.searchParams.get("id"); if (!postId) { throw new StatusError(400, "Missing required parameter: id"); } const post = await db.posts.findUnique({ where: { id: postId } }); if (!post) { throw new StatusError(404, "Post not found"); } return Response.json(post); } ``` --- ## Secure API Route (Token Validation) ```typescript // app/api/protected+api.ts const BEARER_PREFIX = "Bearer "; function getAuthToken(request: Request): string | null { const auth = request.headers.get("Authorization"); if (!auth?.startsWith(BEARER_PREFIX)) return null; return auth.slice(BEARER_PREFIX.length); } export async function GET(request: Request) { const token = getAuthToken(request); if (!token) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } const user = await validateToken(token); if (!user) { return Response.json({ error: "Invalid token" }, { status: 403 }); } return Response.json({ user }); } ``` --- ## Background Tasks (SDK 54+) ```typescript // app/api/webhook+api.ts import { runTask, deferTask } from "expo-server"; export async function POST(request: Request) { const payload = await request.json(); // runTask: executes concurrently, response waits for completion await runTask(async () => { await processWebhookPayload(payload); }); // deferTask: executes AFTER response is sent to client deferTask(async () => { await sendNotification(payload.userId); }); return Response.json({ received: true }); } ``` --- ## Key Limitations - **No dynamic imports** -- external deps with platform binaries cannot be bundled - **Bundles to CommonJS** -- ESM syntax is recommended but transpiles to CJS - **No platform-specific extensions** -- `users+api.web.ts` does not work - **Environment variables** -- non-public env vars (without `EXPO_PUBLIC_` prefix) are accessible since these run server-side - **Deployment** -- use `npx expo export --platform web` and deploy the `dist/` directory -
auth.md 6.1 KB
# Authentication Patterns > Route protection and auth flows. See [SKILL.md](../SKILL.md) for decisions, [core.md](core.md) for navigation basics. --- ## Stack.Protected Pattern (SDK 53+ -- Recommended) ### Session Provider ```typescript // ctx.tsx -- Authentication context import { use, createContext, type PropsWithChildren } from "react"; import { useStorageState } from "./use-storage-state"; interface AuthContextValue { signIn: (token: string) => void; signOut: () => void; session: string | null; isLoading: boolean; } const AuthContext = createContext<AuthContextValue | null>(null); export function useSession(): AuthContextValue { const value = use(AuthContext); if (!value) { throw new Error("useSession must be wrapped in a <SessionProvider />"); } return value; } export function SessionProvider({ children }: PropsWithChildren) { const [[isLoading, session], setSession] = useStorageState("session"); return ( <AuthContext value={{ signIn: (token: string) => setSession(token), signOut: () => setSession(null), session, isLoading, }} > {children} </AuthContext> ); } ``` ### Root Layout with Stack.Protected ```typescript // app/_layout.tsx import { Stack } from "expo-router"; import * as SplashScreen from "expo-splash-screen"; import { SessionProvider, useSession } from "../ctx"; SplashScreen.preventAutoHideAsync(); export default function Root() { return ( <SessionProvider> <SplashScreenController /> <RootNavigator /> </SessionProvider> ); } function SplashScreenController() { const { isLoading } = useSession(); if (!isLoading) { SplashScreen.hide(); } return null; } function RootNavigator() { const { session } = useSession(); return ( <Stack> {/* Protected routes -- only accessible when session exists */} <Stack.Protected guard={!!session}> <Stack.Screen name="(app)" options={{ headerShown: false }} /> </Stack.Protected> {/* Public routes -- only accessible when no session */} <Stack.Protected guard={!session}> <Stack.Screen name="sign-in" options={{ headerShown: false }} /> </Stack.Protected> </Stack> ); } ``` ### Directory Structure ``` app/ ├── _layout.tsx # Root with Stack.Protected ├── sign-in.tsx # Public sign-in screen └── (app)/ # Protected group ├── _layout.tsx # App layout (tabs, etc.) ├── index.tsx # Home screen └── profile.tsx # Profile screen ``` ### Sign-In Screen ```typescript // app/sign-in.tsx import { router } from "expo-router"; import { View, Text, Pressable, TextInput, StyleSheet } from "react-native"; import { useState } from "react"; import { useSession } from "../ctx"; export default function SignIn() { const { signIn } = useSession(); const [email, setEmail] = useState(""); const handleSignIn = async () => { // Your auth logic here (API call, etc.) const token = await authenticateUser(email); signIn(token); router.replace("/"); // Navigate to protected home }; return ( <View style={styles.container}> <Text style={styles.title}>Sign In</Text> <TextInput style={styles.input} placeholder="Email" value={email} onChangeText={setEmail} autoCapitalize="none" /> <Pressable style={styles.button} onPress={handleSignIn}> <Text style={styles.buttonText}>Sign In</Text> </Pressable> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: "center", padding: 16 }, title: { fontSize: 24, fontWeight: "bold", marginBottom: 24, textAlign: "center" }, input: { borderWidth: 1, borderColor: "#ccc", padding: 12, borderRadius: 8, marginBottom: 16 }, button: { backgroundColor: "#007AFF", padding: 16, borderRadius: 8, alignItems: "center" }, buttonText: { color: "#fff", fontWeight: "600", fontSize: 16 }, }); ``` --- ## How Stack.Protected Guard Works ``` User authenticated (session exists): guard={!!session} -> true -> (app) screens accessible guard={!session} -> false -> sign-in screen hidden User not authenticated (no session): guard={!!session} -> false -> (app) screens hidden guard={!session} -> true -> sign-in screen accessible User navigates to protected route while unauthenticated: -> Automatically redirected to first available unprotected screen (sign-in) User signs out while on protected screen: -> guard flips to false -> redirected to sign-in automatically ``` --- ## Modal Sign-In Pattern (Alternative) For apps where you want the main content visible behind a sign-in overlay: ```typescript // app/_layout.tsx import { Stack } from "expo-router"; export const unstable_settings = { initialRouteName: "(root)", }; export default function AppLayout() { return ( <Stack> <Stack.Screen name="(root)" options={{ headerShown: false }} /> <Stack.Screen name="sign-in" options={{ presentation: "modal", // Prevent dismissing the modal without signing in gestureEnabled: false, headerShown: false, }} /> </Stack> ); } ``` **Trade-off:** Modal sign-in preserves deep links better (the target route is already loaded behind the modal), but requires more careful handling of the unauthenticated state since routes render in the background. --- ## Legacy Redirect Pattern (SDK 52 and Earlier) For projects not yet on SDK 53, use the `Redirect` component in a layout: ```typescript // app/(app)/_layout.tsx import { Redirect, Stack } from "expo-router"; import { Text } from "react-native"; import { useSession } from "../../ctx"; export default function AppLayout() { const { session, isLoading } = useSession(); if (isLoading) { return <Text>Loading...</Text>; } if (!session) { return <Redirect href="/sign-in" />; } return <Stack />; } ``` **Why Stack.Protected is better:** The Redirect approach renders the protected layout momentarily before redirecting. Stack.Protected prevents the screen from rendering at all when guard is false. -
core.md 13.3 KB
# Expo Router Core Patterns > Layouts, tabs, navigation hooks, dynamic routes, modals, typed routes. See [SKILL.md](../SKILL.md) for decisions and philosophy. --- ## Directory Structure ``` app/ ├── _layout.tsx # Root layout (Stack navigator) ├── index.tsx # Home route (/) ├── about.tsx # /about ├── +not-found.tsx # 404 fallback ├── modal.tsx # /modal (configured as modal in root layout) ├── settings/ │ ├── _layout.tsx # Settings stack layout │ ├── index.tsx # /settings │ └── profile.tsx # /settings/profile ├── users/ │ ├── _layout.tsx # Users layout │ ├── index.tsx # /users │ └── [id].tsx # /users/:id (dynamic) ├── docs/ │ └── [...slug].tsx # /docs/a/b/c (catch-all) ├── (tabs)/ # Tab navigator (group -- not in URL) │ ├── _layout.tsx # Tab layout │ ├── home.tsx # Tab: home │ ├── search.tsx # Tab: search │ └── profile.tsx # Tab: profile └── api/ └── users+api.ts # API route: /api/users ``` --- ## Root Layout with Stack ```typescript // app/_layout.tsx import { Stack } from "expo-router"; export default function RootLayout() { return ( <Stack> <Stack.Screen name="index" options={{ title: "Home" }} /> <Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="settings" options={{ title: "Settings" }} /> <Stack.Screen name="modal" options={{ presentation: "modal", headerShown: true, title: "Modal", }} /> <Stack.Screen name="+not-found" /> </Stack> ); } ``` --- ## Tab Navigation ```typescript // app/(tabs)/_layout.tsx import { Tabs } from "expo-router"; const TAB_ICON_SIZE = 24; export default function TabLayout() { return ( <Tabs screenOptions={{ tabBarActiveTintColor: "#007AFF", tabBarInactiveTintColor: "#8E8E93", headerShown: true, }} > <Tabs.Screen name="home" options={{ title: "Home", tabBarIcon: ({ color }) => <IconComponent name="home" size={TAB_ICON_SIZE} color={color} />, }} /> <Tabs.Screen name="search" options={{ title: "Search", tabBarIcon: ({ color }) => <IconComponent name="search" size={TAB_ICON_SIZE} color={color} />, }} /> <Tabs.Screen name="profile" options={{ title: "Profile", tabBarIcon: ({ color }) => <IconComponent name="person" size={TAB_ICON_SIZE} color={color} />, }} /> </Tabs> ); } ``` --- ## Nested Stack Inside Tabs ``` app/ ├── (tabs)/ │ ├── _layout.tsx # Tab navigator │ ├── feed/ │ │ ├── _layout.tsx # Stack navigator for feed tab │ │ ├── index.tsx # Feed list (/feed) │ │ └── [postId].tsx # Post detail (/feed/:postId) │ └── settings.tsx # Settings tab ``` ```typescript // app/(tabs)/feed/_layout.tsx import { Stack } from "expo-router"; export default function FeedLayout() { return ( <Stack> <Stack.Screen name="index" options={{ title: "Feed" }} /> <Stack.Screen name="[postId]" options={{ title: "Post", headerBackTitle: "Feed" }} /> </Stack> ); } // app/(tabs)/feed/[postId].tsx import { useLocalSearchParams, Stack } from "expo-router"; import { View, Text, StyleSheet } from "react-native"; export default function PostDetailScreen() { const { postId } = useLocalSearchParams<{ postId: string }>(); return ( <> {/* Dynamic screen options -- overrides layout config */} <Stack.Screen options={{ title: `Post ${postId}` }} /> <View style={styles.container}> <Text style={styles.title}>Post {postId}</Text> </View> </> ); } const styles = StyleSheet.create({ container: { flex: 1, padding: 16 }, title: { fontSize: 24, fontWeight: "bold" }, }); ``` --- ## Navigation Hooks Usage ```typescript // components/navigation-example.tsx import { useRouter, useLocalSearchParams, usePathname, useSegments, Link, } from "expo-router"; import { View, Text, Pressable, StyleSheet } from "react-native"; export function NavigationExample() { const router = useRouter(); const { id } = useLocalSearchParams<{ id: string }>(); const pathname = usePathname(); const segments = useSegments(); const handlePush = () => { router.push("/users/123"); }; const handlePushWithParams = () => { // Object form for typed routes router.push({ pathname: "/users/[id]", params: { id: "456" }, }); }; const handleReplace = () => { // Replace -- no back button to return router.replace("/home"); }; const handleDismiss = () => { // Dismiss modal or pop stack screen if (router.canDismiss()) { router.dismissTo("/home"); } }; return ( <View style={styles.container}> {/* Declarative navigation -- preferred for static links */} <Link href="/about" style={styles.link}> <Text>About</Text> </Link> {/* Link with asChild -- passes navigation behavior to child */} <Link href="/users/123" asChild> <Pressable style={styles.button}> <Text style={styles.buttonText}>User Profile</Text> </Pressable> </Link> {/* Link with params object */} <Link href={{ pathname: "/search", params: { query: "expo" } }} style={styles.link} > <Text>Search for Expo</Text> </Link> {/* Imperative navigation */} <Pressable style={styles.button} onPress={handlePush}> <Text style={styles.buttonText}>Push Screen</Text> </Pressable> <Text style={styles.info}>Path: {pathname}</Text> <Text style={styles.info}>Segments: {segments.join("/")}</Text> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, padding: 16, gap: 12 }, link: { padding: 12, backgroundColor: "#f0f0f0", borderRadius: 8 }, button: { padding: 12, backgroundColor: "#007AFF", borderRadius: 8, alignItems: "center" }, buttonText: { color: "#fff", fontWeight: "600" }, info: { fontSize: 12, color: "#666" }, }); ``` --- ## Dynamic Route ```typescript // app/users/[id].tsx import { useLocalSearchParams, Stack } from "expo-router"; import { View, Text } from "react-native"; export default function UserScreen() { const { id } = useLocalSearchParams<{ id: string }>(); return ( <> <Stack.Screen options={{ title: `User ${id}` }} /> <View style={{ flex: 1, padding: 16 }}> <Text>User ID: {id}</Text> </View> </> ); } ``` --- ## Catch-All Route ```typescript // app/docs/[...slug].tsx import { useLocalSearchParams } from "expo-router"; import { View, Text } from "react-native"; export default function DocsScreen() { const { slug } = useLocalSearchParams<{ slug: string[] }>(); // IMPORTANT: Normalize -- single segment returns string, multiple returns array const segments = Array.isArray(slug) ? slug : [slug]; const path = segments.join("/"); return ( <View style={{ flex: 1, padding: 16 }}> <Text>Docs path: {path}</Text> <Text>Depth: {segments.length} levels</Text> </View> ); } ``` --- ## Modal Route ```typescript // app/modal.tsx -- the file itself is a regular route import { useRouter } from "expo-router"; import { View, Text, Pressable, StyleSheet } from "react-native"; export default function ModalScreen() { const router = useRouter(); return ( <View style={styles.container}> <Text style={styles.title}>Modal Content</Text> <Pressable style={styles.closeButton} onPress={() => router.back()}> <Text style={styles.closeText}>Close</Text> </Pressable> </View> ); } // The modal PRESENTATION is configured in the PARENT layout: // app/_layout.tsx -> <Stack.Screen name="modal" options={{ presentation: "modal" }} /> const styles = StyleSheet.create({ container: { flex: 1, padding: 16, alignItems: "center", justifyContent: "center" }, title: { fontSize: 24, fontWeight: "bold", marginBottom: 16 }, closeButton: { padding: 12, backgroundColor: "#007AFF", borderRadius: 8 }, closeText: { color: "#fff", fontWeight: "600" }, }); ``` --- ## Form Sheet (iOS) ```typescript // Configure in parent layout <Stack.Screen name="sheet" options={{ presentation: "formSheet", sheetGrabberVisible: true, sheetCornerRadius: 16, // Optional: control sheet height // sheetInitialDetentIndex: 0, // sheetAllowedDetents: [0.5, 1.0], }} /> ``` --- ## Shared Routes Between Tab Groups When multiple tabs need to show the same screen (e.g., a user profile accessible from both feed and search): ``` app/(tabs)/ ├── _layout.tsx ├── (feed)/ │ └── index.tsx # Feed tab content ├── (search)/ │ └── search.tsx # Search tab content └── (feed,search)/ # Shared between both groups ├── _layout.tsx └── users/ └── [username].tsx # Accessible from both feed and search ``` ```typescript // app/(tabs)/(feed,search)/users/[username].tsx import { useLocalSearchParams } from "expo-router"; import { View, Text, StyleSheet } from "react-native"; export default function UserProfileScreen() { const { username } = useLocalSearchParams<{ username: string }>(); return ( <View style={styles.container}> <Text style={styles.title}>@{username}</Text> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, padding: 16 }, title: { fontSize: 24, fontWeight: "bold" }, }); ``` --- ## Headless Tabs (Custom Tab Bar UI) When the default tab bar doesn't fit your design, use headless tab components from `expo-router/ui` for full control over rendering: ```typescript // app/(tabs)/_layout.tsx import { Tabs, TabList, TabTrigger, TabSlot } from "expo-router/ui"; import { Text, StyleSheet } from "react-native"; const TAB_BAR_HEIGHT = 60; export default function CustomTabLayout() { return ( <Tabs> {/* Content area -- renders the active tab's content */} <TabSlot /> {/* Fully custom tab bar */} <TabList style={styles.tabBar}> <TabTrigger name="home" href="/" style={styles.tab}> <Text>Home</Text> </TabTrigger> <TabTrigger name="search" href="/search" style={styles.tab}> <Text>Search</Text> </TabTrigger> <TabTrigger name="profile" href="/profile" style={styles.tab}> <Text>Profile</Text> </TabTrigger> </TabList> </Tabs> ); } const styles = StyleSheet.create({ tabBar: { flexDirection: "row", height: TAB_BAR_HEIGHT, backgroundColor: "#fff", borderTopWidth: 1, borderTopColor: "#e0e0e0", }, tab: { flex: 1, alignItems: "center", justifyContent: "center" }, }); ``` --- ## Typed Routes Setup ```json // app.json { "expo": { "experiments": { "typedRoutes": true } } } ``` ```typescript // After enabling and starting dev server, routes are auto-typed: import { useRouter, useLocalSearchParams, Link } from "expo-router"; export function TypedNavigationExample() { const router = useRouter(); // TypeScript validates route exists router.push("/about"); // TypeScript requires correct params for dynamic routes router.push({ pathname: "/users/[id]", params: { id: "123" } }); // TypeScript errors on invalid routes // router.push("/nonexistent"); // Error! // Typed params from route const { id } = useLocalSearchParams<"/users/[id]">(); // id is typed as string // Typed Link return <Link href={{ pathname: "/users/[id]", params: { id: "456" } }}>User</Link>; } ``` --- ## 404 Not Found Route ```typescript // app/+not-found.tsx import { Link, Stack } from "expo-router"; import { View, Text, StyleSheet } from "react-native"; export default function NotFoundScreen() { return ( <> <Stack.Screen options={{ title: "Not Found" }} /> <View style={styles.container}> <Text style={styles.title}>This screen does not exist.</Text> <Link href="/" style={styles.link}> <Text style={styles.linkText}>Go to home screen</Text> </Link> </View> </> ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: "center", justifyContent: "center", padding: 20 }, title: { fontSize: 20, fontWeight: "bold" }, link: { marginTop: 16, paddingVertical: 16 }, linkText: { fontSize: 14, color: "#2e78b7" }, }); ``` --- ## useFocusEffect for Data Fetching ```typescript // Fetch data when screen comes into focus (e.g., returning from edit screen) import { useFocusEffect } from "expo-router"; import { useCallback, useState } from "react"; export default function UserListScreen() { const [users, setUsers] = useState([]); useFocusEffect( useCallback(() => { // Runs on focus, cleanup on blur let isActive = true; async function fetchUsers() { const data = await getUsers(); if (isActive) setUsers(data); } fetchUsers(); return () => { isActive = false; // Prevent state update after blur }; }, []), ); // Render users... } ``` -
web.md 5.1 KB
# Web: Static Rendering and Head Metadata > Static rendering, SEO, and head metadata for web output. See [SKILL.md](../SKILL.md) for decisions, [core.md](core.md) for navigation basics. --- ## Enabling Static Rendering ```json // app.json { "expo": { "web": { "output": "static" } } } ``` Static rendering generates individual HTML files at build time. Each route becomes a separate `.html` file for SEO and fast initial loads. ```bash # Development npx expo start # Production export npx expo export --platform web # Generates dist/ directory -- deploy to any static host ``` --- ## Head Metadata Use the `Head` component from `expo-router/head` to manage `<title>` and `<meta>` tags per page: ```typescript // app/about.tsx import Head from "expo-router/head"; import { Text, View, StyleSheet } from "react-native"; export default function AboutPage() { return ( <> <Head> <title>About Us | MyApp</title> <meta name="description" content="Learn about our mission and team." /> <meta property="og:title" content="About Us" /> <meta property="og:description" content="Learn about our mission." /> </Head> <View style={styles.container}> <Text style={styles.heading}>About Us</Text> </View> </> ); } const styles = StyleSheet.create({ container: { flex: 1, padding: 16 }, heading: { fontSize: 32, fontWeight: "bold" }, }); ``` **Note:** `Head` renders on web only. On native platforms, it is a no-op. This is safe to include in universal components. --- ## Dynamic Head Metadata ```typescript // app/posts/[id].tsx import Head from "expo-router/head"; import { useLocalSearchParams } from "expo-router"; import { Text, View } from "react-native"; export default function PostPage() { const { id } = useLocalSearchParams<{ id: string }>(); // In a real app, fetch post data based on id const title = `Post ${id}`; return ( <> <Head> <title>{title} | MyBlog</title> <meta name="description" content={`Read post ${id}`} /> </Head> <View style={{ flex: 1, padding: 16 }}> <Text>{title}</Text> </View> </> ); } ``` --- ## generateStaticParams for Dynamic Routes Dynamic routes (`[id].tsx`) require `generateStaticParams` to pre-render pages at build time. Without it, dynamic routes are not included in the static output. ```typescript // app/posts/[id].tsx import { useLocalSearchParams } from "expo-router"; import Head from "expo-router/head"; import { Text, View } from "react-native"; // Runs at BUILD TIME in Node.js -- no React Native APIs available export async function generateStaticParams(): Promise<Record<string, string>[]> { const posts = await fetchAllPosts(); // API call, file read, etc. return posts.map((post) => ({ id: post.id })); // Generates: /posts/1.html, /posts/2.html, ... } export default function PostPage() { const { id } = useLocalSearchParams<{ id: string }>(); return ( <> <Head> <title>Post {id}</title> </Head> <View style={{ flex: 1, padding: 16 }}> <Text>Post {id}</Text> </View> </> ); } ``` **Key constraint:** `generateStaticParams` runs in Node.js during the build. It can access `process.cwd()`, environment variables, and the filesystem -- but NOT browser APIs, React Native APIs, or native modules. --- ## Root HTML Customization Create `app/+html.tsx` to customize the HTML wrapper for all pages. This runs in Node.js only. ```typescript // app/+html.tsx import { ScrollViewStyleReset } from "expo-router/html"; import type { PropsWithChildren } from "react"; export default function Root({ children }: PropsWithChildren) { return ( <html lang="en"> <head> <meta charSet="utf-8" /> <meta httpEquiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" /> {/* ScrollViewStyleReset prevents overflow issues with React Native Web */} <ScrollViewStyleReset /> </head> <body>{children}</body> </html> ); } ``` --- ## Static vs Server Output | Feature | `"static"` | `"server"` | | ----------------------- | ------------------------------------- | -------------------------------------- | | Output | Individual `.html` files | Server bundle + client bundle | | Dynamic routes | Requires `generateStaticParams` | Rendered on request | | API routes (`+api.ts`) | Not available | Available | | Deployment | Any static host (Netlify, Vercel, S3) | Requires server (EAS Hosting, Node.js) | | SEO | Excellent (pre-rendered HTML) | Good (SSR on request) | | React Server Components | No | Yes (experimental) | Choose `"static"` for content sites, marketing pages, and blogs. Choose `"server"` when you need API routes, dynamic server-rendered pages, or React Server Components.
-
-
reference.md 6.3 KB
# Expo Router Quick Reference > Decision frameworks, version compatibility, and quick-lookup tables. See [SKILL.md](SKILL.md) for decisions, philosophy, and red flags. --- ## Route Type Decision Framework ``` What type of route do you need? | +-> Static page (about, settings)? | +-> about.tsx -> /about | +-> Default/index for a directory? | +-> index.tsx -> / (or parent path) | +-> Dynamic content (user profile, product)? | +-> [id].tsx -> /users/:id | +-> Variable-depth path (docs, breadcrumbs)? | +-> [...slug].tsx -> /docs/a/b/c | +-> Tab navigation? | +-> (tabs)/ group with Tabs in _layout.tsx | +-> Auth-protected section? | +-> Stack.Protected with guard prop (SDK 53+) | +-> Redirect component in layout (SDK 52) | +-> Modal/sheet overlay? | +-> presentation: "modal" or "formSheet" in parent layout | +-> Server endpoint? | +-> filename+api.ts with HTTP method exports | +-> 404 fallback? +-> +not-found.tsx at desired directory level ``` --- ## Navigation Method Decision Framework ``` How should navigation happen? | +-> Static link in UI? | +-> <Link href="/path"> (declarative, preferred) | +-> Programmatic navigation in event handler? | +-> router.push("/path") -- adds to history | +-> Replace current screen (login -> home)? | +-> router.replace("/path") -- no back | +-> Go back one screen? | +-> router.back() | +-> Dismiss modal/sheet? | +-> router.dismiss() -- pop one in nearest stack | +-> Dismiss to specific screen in stack? | +-> router.dismissTo("/path") | +-> Dismiss all screens to root of stack? | +-> router.dismissAll() | +-> Check if navigation is possible? | +-> router.canGoBack() / router.canDismiss() | +-> Preload a heavy screen? | +-> router.prefetch("/path") | +-> Redirect during render (not in handler)? +-> <Redirect href="/path" /> component ``` --- ## Layout Navigator Selection ``` How should routes be presented? | +-> Push/pop screens with back button? | +-> Stack (default) | +-> Bottom tab bar with persistent screens? | +-> Tabs (JS-based, full control) | +-> NativeTabs (SDK 54+, alpha, iOS Liquid Glass) | +-> Fully custom tab bar UI? | +-> Headless tabs from expo-router/ui (TabList, TabTrigger, TabSlot) | +-> Overlay on top of current content? | +-> Stack.Screen with presentation: "modal" or "formSheet" | +-> Just render child route content? +-> <Slot /> (raw outlet, no navigator chrome) ``` --- ## Hook Selection | Hook | Returns | Re-renders when | Use for | | ---------------------------- | ----------------------- | ----------------------------------- | ----------------------------------- | | `useLocalSearchParams<T>()` | Route + query params | Screen is focused and params change | Screen-specific param access | | `useGlobalSearchParams<T>()` | Route + query params | ANY route's params change | Background analytics, rarely needed | | `useRouter()` | Router object | Never (stable ref) | Imperative navigation in handlers | | `usePathname()` | Current path string | Path changes | Displaying current location | | `useSegments()` | File segment array | Segments change | Auth checks, conditional logic | | `useFocusEffect(cb)` | void | Screen focus/blur | Data fetching on screen focus | | `useNavigation(parent?)` | React Navigation object | Varies | Low-level navigator control | --- ## File Convention Quick Reference | Convention | Example | Purpose | | ---------------- | ------------------------ | ---------------------------- | | `index.tsx` | `app/index.tsx` | Default route for directory | | `[param].tsx` | `app/users/[id].tsx` | Dynamic route segment | | `[...param].tsx` | `app/docs/[...slug].tsx` | Catch-all route | | `_layout.tsx` | `app/(tabs)/_layout.tsx` | Navigator wrapping siblings | | `(group)/` | `app/(tabs)/` | URL-invisible grouping | | `(a,b)/` | `app/(feed,search)/` | Shared routes between groups | | `+not-found.tsx` | `app/+not-found.tsx` | 404 fallback | | `+api.ts` | `app/api/users+api.ts` | Server-side API route | | `+html.tsx` | `app/+html.tsx` | Root HTML wrapper (web) | | `+middleware.ts` | `app/+middleware.ts` | Server middleware (v6+) | --- ## Version Compatibility | Feature | Minimum Version | Notes | | ------------------------------------ | ----------------------- | ----------------------------------------- | | File-based routing | Expo Router v1 / SDK 49 | Core feature | | Typed routes | Expo Router v2 / SDK 50 | `experiments.typedRoutes` in app.json | | `dismissTo` / `dismissAll` | Expo Router v4 / SDK 52 | Stack dismiss methods | | Headless tabs (`expo-router/ui`) | Expo Router v4 / SDK 52 | TabList, TabTrigger, TabSlot | | API routes (`+api.ts`) | Expo Router v3 / SDK 50 | Requires `web.output: "server"` | | Stack.Protected (guard) | Expo Router v5 / SDK 53 | Replaces redirect-based auth | | Build-time redirects/rewrites | Expo Router v5 / SDK 53 | Config in app.json | | React Server Functions | Expo Router v5 / SDK 53 | Beta, requires server output | | NativeTabs (Liquid Glass) | Expo Router v6 / SDK 54 | Alpha, import from `unstable-native-tabs` | | Link.Preview / Link.Menu | Expo Router v6 / SDK 54 | iOS context menus | | Server middleware (`+middleware.ts`) | Expo Router v6 / SDK 54 | Edge middleware | | Stack.Toolbar | Expo Router v7 / SDK 55 | Toolbar component | | SplitView (experimental) | Expo Router v7 / SDK 55 | iPad/desktop split views | --- ## Anti-Patterns > See [SKILL.md](SKILL.md) RED FLAGS section for the full anti-pattern list with explanations. -
SKILL.md 17 KB
--- name: mobile-navigation-expo-router description: File-based routing and navigation for Expo/React Native --- # Expo Router Patterns > **Quick Guide:** File-based routing for React Native and web. Files in `app/` become routes automatically. Use `_layout.tsx` for navigation structure (Stack, Tabs), groups `(name)/` for URL-invisible organization, `[param]` for dynamic segments. SDK 53+: use `Stack.Protected` with a `guard` prop for authentication. Enable `typedRoutes` for compile-time route safety. API routes use `+api.ts` suffix. --- <critical_requirements> ## CRITICAL: Before Using This Skill > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST define navigation structure in `_layout.tsx` files -- screens without a layout parent default to a basic Stack)** **(You MUST use `Stack.Protected` with `guard` prop for authentication flows in SDK 53+ -- NOT imperative redirects in useEffect)** **(You MUST use `useLocalSearchParams` for route params in screens -- `useGlobalSearchParams` causes unnecessary re-renders on unfocused screens)** **(You MUST enable `typedRoutes` in app.json experiments for compile-time route validation -- catches invalid navigation at build time)** </critical_requirements> --- **Auto-detection:** expo-router, Expo Router, file-based routing, \_layout.tsx, Stack.Screen, Tabs.Screen, useRouter, useLocalSearchParams, useSegments, usePathname, Link href, router.push, router.replace, router.dismiss, router.dismissTo, +api.ts, +not-found, Stack.Protected, generateStaticParams, expo-router/head, Slot, Redirect, useFocusEffect, NativeTabs, headless tabs, TabSlot, TabTrigger **When to use:** - Setting up file-based navigation in an Expo app - Implementing authentication flows with route protection - Creating tab, stack, or modal navigation layouts - Building API routes for server-side logic - Configuring typed routes for compile-time safety - Adding deep linking and static rendering for web **Key patterns covered:** - File convention: `_layout.tsx`, `[param]`, `[...slug]`, `(group)/`, `+api.ts`, `+not-found.tsx` - Layout navigators: Stack, Tabs, headless tabs, native tabs - Authentication: `Stack.Protected` guard pattern (SDK 53+), redirect pattern (SDK 52) - Navigation hooks: `useRouter`, `useLocalSearchParams`, `useSegments`, `usePathname` - API routes with standard Request/Response - Typed routes with auto-generated TypeScript definitions - Modal routes, shared routes between tabs, nested navigation **When NOT to use:** - Apps that need fully custom native navigation controllers beyond what React Navigation provides - Simple single-screen apps with no navigation - Web-only projects where a web-native router is more appropriate --- <philosophy> ## Philosophy Expo Router maps the filesystem to your navigation hierarchy. Every file in `app/` is a route; every `_layout.tsx` defines how its sibling routes are presented (stack, tabs, drawer). This convention-over-configuration approach means: 1. **URLs are first-class** -- every screen has a URL, enabling deep linking on mobile and SEO on web without extra configuration 2. **Layouts are composable** -- nest `_layout.tsx` files to create any navigation structure (tabs containing stacks containing modals) 3. **The file tree IS the sitemap** -- new developers understand navigation by reading the directory structure, not a central config 4. **Universal by default** -- the same route definitions work on iOS, Android, and web **Mental model:** Think of `app/` as a website. `_layout.tsx` files are the "chrome" (nav bars, tab bars). Route files are the "pages." Groups `(name)/` organize without affecting URLs. This maps directly to how web routing works, which is intentional -- Expo Router is built on top of React Navigation but presents a web-like API. </philosophy> --- <patterns> ## Core Patterns ### Pattern 1: File Conventions Every file in `app/` maps to a route. Special characters change behavior: | File | URL | Purpose | | ---------------- | -------------------- | ---------------------------------------- | | `index.tsx` | `/` (or parent path) | Default route for directory | | `about.tsx` | `/about` | Static route | | `[id].tsx` | `/:id` | Dynamic segment | | `[...slug].tsx` | `/a/b/c` | Catch-all segments | | `_layout.tsx` | N/A | Wraps sibling routes in navigator | | `(group)/` | Not in URL | Organizes routes without URL impact | | `+not-found.tsx` | N/A | 404 fallback for unmatched routes | | `+api.ts` | Server endpoint | API route handler | | `+html.tsx` | N/A | Root HTML wrapper (web static rendering) | **Key insight:** Groups `(name)/` are purely organizational. `(tabs)/home.tsx` and `home.tsx` both resolve to `/home`. Use groups to apply different layouts to different route sets without changing URLs. > Full directory structure examples: [examples/core.md](examples/core.md) --- ### Pattern 2: Layout Routes `_layout.tsx` files wrap their sibling routes in a navigator. The layout determines HOW routes are presented (stack push, tab switch, modal overlay). ```typescript // app/_layout.tsx -- Root layout wrapping entire app import { Stack } from "expo-router"; export default function RootLayout() { return ( <Stack> <Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="modal" options={{ presentation: "modal" }} /> <Stack.Screen name="+not-found" /> </Stack> ); } ``` **Why this matters:** Without a `_layout.tsx`, routes get a default Stack navigator with default headers. Always define layouts explicitly for control over headers, transitions, and navigation structure. **Gotcha:** The `name` prop in `Stack.Screen`/`Tabs.Screen` must match the filename (without extension) or directory name exactly. `name="(tabs)"` matches the `(tabs)/` directory. > Full layout examples (tabs, nested stacks, drawers): [examples/core.md](examples/core.md) --- ### Pattern 3: Navigation Hooks ```typescript import { useRouter, useLocalSearchParams, usePathname, useSegments, } from "expo-router"; // useRouter -- imperative navigation const router = useRouter(); router.push("/users/123"); // Add to stack router.replace("/home"); // Replace current (no back) router.back(); // Go back router.dismiss(); // Pop one screen in nearest stack router.dismissTo("/home"); // Pop until reaching /home router.dismissAll(); // Pop to first screen in stack router.canGoBack(); // Check if back is possible router.canDismiss(); // Check if dismiss is possible router.prefetch("/heavy-screen"); // Preload in background // useLocalSearchParams -- route params for focused screen only const { id } = useLocalSearchParams<{ id: string }>(); // usePathname -- current path without query params const pathname = usePathname(); // "/users/123" // useSegments -- raw file segments of current route const segments = useSegments(); // ["users", "[id]"] ``` **Critical:** Use `useLocalSearchParams` over `useGlobalSearchParams`. The global variant re-renders the component whenever ANY route's params change -- even when the screen is unfocused in the background. Local only updates when the screen is focused. > Full hook usage examples: [examples/core.md](examples/core.md) --- ### Pattern 4: Authentication with Stack.Protected (SDK 53+) The recommended pattern uses `Stack.Protected` with a `guard` prop to declaratively show/hide routes based on auth state. ```typescript // app/_layout.tsx import { Stack } from "expo-router"; import { useSession } from "../ctx"; function RootNavigator() { const { session } = useSession(); return ( <Stack> <Stack.Protected guard={!!session}> <Stack.Screen name="(app)" /> </Stack.Protected> <Stack.Protected guard={!session}> <Stack.Screen name="sign-in" /> </Stack.Protected> </Stack> ); } ``` **How `guard` works:** When `guard` is `false`, the screens inside are inaccessible. If a user tries to navigate to a protected screen, or a screen becomes protected while active, they are redirected to the first available unprotected screen. **Gotcha:** All routes remain defined and accessible in the file system. `Stack.Protected` controls runtime accessibility, not build-time elimination. Deep links to protected routes trigger redirects to the sign-in screen. > Full auth pattern with SessionProvider and splash screen: [examples/auth.md](examples/auth.md) > Legacy redirect pattern (SDK 52): [examples/auth.md](examples/auth.md) --- ### Pattern 5: Modal Routes Modals are defined as regular route files but configured with `presentation: "modal"` in the parent layout. ```typescript // app/_layout.tsx <Stack> <Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="modal" options={{ presentation: "modal", headerShown: true, title: "Settings", }} /> <Stack.Screen name="sheet" options={{ presentation: "formSheet", sheetGrabberVisible: true, sheetCornerRadius: 16, }} /> </Stack> ``` **Key insight:** Modals sit outside tab groups so they overlay the entire app. Navigation to a modal from any tab: `router.push("/modal")`. Dismiss with `router.back()` or `router.dismiss()`. > Full modal examples: [examples/core.md](examples/core.md) --- ### Pattern 6: API Routes Files with `+api.ts` suffix define server-side endpoints. They use standard Web `Request`/`Response` APIs. ```typescript // app/api/users+api.ts export async function GET(request: Request) { const users = await db.users.findMany(); return Response.json(users); } export async function POST(request: Request) { const body = await request.json(); const user = await db.users.create(body); return Response.json(user, { status: 201 }); } ``` **Requires** `web.output: "server"` in app.json. For native apps, set `origin` in the expo-router plugin config to point to your deployed server. **Limitation:** API routes bundle to CommonJS, no dynamic imports, no platform-specific extensions (`+api.web.ts` does not work). > Full API route examples with error handling: [examples/api-routes.md](examples/api-routes.md) --- ### Pattern 7: Typed Routes Enable compile-time route validation by setting `experiments.typedRoutes: true` in app.json. The dev server auto-generates type definitions. ```typescript // With typedRoutes enabled: router.push("/about"); // OK router.push("/nonexistent"); // TypeScript error router.push({ pathname: "/users/[id]", params: { id: "123" }, // Typed params required }); // Typed search params const { id } = useLocalSearchParams<"/users/[id]">(); // id is typed as string ``` **Gotcha:** Generated types are git-ignored. CI pipelines need `npx expo customize tsconfig.json` to regenerate types before type-checking. Relative paths are not supported -- always use absolute paths. > Typed routes setup and examples: [examples/core.md](examples/core.md) --- ### Pattern 8: Static Rendering and Head Metadata (Web) Static rendering generates HTML at build time for SEO and fast initial loads. ```typescript // app.json: { "web": { "output": "static" } } // app/about.tsx import Head from "expo-router/head"; import { Text } from "react-native"; export default function AboutPage() { return ( <> <Head> <title>About Us</title> <meta name="description" content="Learn about our company" /> </Head> <Text>About page content</Text> </> ); } ``` For dynamic routes, export `generateStaticParams` to pre-render pages at build time: ```typescript export async function generateStaticParams() { const posts = await getPosts(); return posts.map((post) => ({ id: post.id })); } ``` > Full static rendering and Head examples: [examples/web.md](examples/web.md) </patterns> --- **Detailed Resources:** - [examples/core.md](examples/core.md) - Directory structure, layouts, tabs, navigation hooks, typed routes, modals - [examples/auth.md](examples/auth.md) - Stack.Protected pattern, SessionProvider, legacy redirect pattern - [examples/api-routes.md](examples/api-routes.md) - API route handlers, error handling, deployment - [examples/web.md](examples/web.md) - Static rendering, Head metadata, root HTML - [reference.md](reference.md) - Decision frameworks, version compatibility --- <decision_framework> ## Decision Frameworks Expo Router provides multiple navigation patterns. The key decisions: 1. **Route type** -- static, dynamic, catch-all, grouped, API? See [reference.md](reference.md) for the full route type decision tree. 2. **Navigation method** -- declarative `<Link>` vs imperative `router.push/replace/dismiss`? See [reference.md](reference.md) for the navigation method decision tree. 3. **Layout navigator** -- Stack, Tabs, NativeTabs, headless tabs, or `<Slot />`? See [reference.md](reference.md) for the layout navigator selection guide. 4. **Hook choice** -- `useLocalSearchParams` vs `useGlobalSearchParams`, `useRouter` vs `<Link>`, `useFocusEffect` vs `useEffect`? See [reference.md](reference.md) for the hook selection table. **Quick rules:** - Prefer `<Link>` for static navigation in UI, `router.push` for programmatic navigation in event handlers - Always use `useLocalSearchParams` unless you specifically need background screen updates - Use `useFocusEffect` instead of `useEffect` when data should refresh on screen focus </decision_framework> --- <red_flags> ## RED FLAGS **High Priority Issues:** - **Using `useGlobalSearchParams` when `useLocalSearchParams` works** -- global causes re-renders on ALL route changes, even when screen is in background; use local for screen-specific params - **Imperative redirects in useEffect for auth (SDK 53+)** -- use `Stack.Protected` with `guard` prop instead; it's declarative, handles edge cases, and integrates with deep linking correctly - **Missing `_layout.tsx` in route groups** -- without a layout, the default Stack has default headers and no control over transitions; always define layouts explicitly - **Storing secrets in API route responses without authentication** -- API routes are public endpoints; validate authentication tokens before returning sensitive data **Medium Priority Issues:** - **`name` prop mismatch in layout screens** -- `Stack.Screen name="tabs"` does not match directory `(tabs)/`; must be `name="(tabs)"` exactly - **Not using `presentation: "modal"` in parent layout** -- configuring modal in the modal file's own layout does nothing; modals must be configured in the parent navigator - **Calling `router.replace` in initial render** -- causes navigation before the navigator is ready; use `Redirect` component or `useFocusEffect` instead **Gotchas & Edge Cases:** - **Deep links to protected routes:** `Stack.Protected` redirects to the first unprotected screen -- deep link target is lost unless you store and replay it after auth - **Catch-all `[...slug]` params:** Always an array, but `useLocalSearchParams` may return a string if only one segment; always normalize with `Array.isArray(slug) ? slug : [slug]` - **Tab groups reset on tab switch:** By default, switching tabs resets the tab's stack; use `backBehavior: "history"` in Tabs layout to preserve stack per tab - **Android 5-tab limit:** Material Design constrains bottom tabs to 5; native tabs enforce this - **`+not-found.tsx` only catches at its directory level** -- a `+not-found.tsx` in `app/` won't catch 404s inside `app/docs/`; each directory needs its own if required - **Static rendering `generateStaticParams` runs in Node.js** -- no access to React Native APIs, browser APIs, or native modules - **API route limitation:** No dynamic imports, no platform-specific extensions (`+api.web.ts` is invalid), bundles to CommonJS - **Typed routes are git-ignored** -- CI pipelines fail type checks unless types are regenerated with `npx expo customize tsconfig.json` - **Route files require `export default`** -- Expo Router discovers screens via default exports; this overrides project "named exports only" conventions for files in `app/` </red_flags> --- <critical_reminders> ## CRITICAL REMINDERS > **All code must follow project conventions in CLAUDE.md** **(You MUST define navigation structure in `_layout.tsx` files -- screens without a layout parent default to a basic Stack)** **(You MUST use `Stack.Protected` with `guard` prop for authentication flows in SDK 53+ -- NOT imperative redirects in useEffect)** **(You MUST use `useLocalSearchParams` for route params in screens -- `useGlobalSearchParams` causes unnecessary re-renders on unfocused screens)** **(You MUST enable `typedRoutes` in app.json experiments for compile-time route validation -- catches invalid navigation at build time)** **Failure to follow these rules will cause navigation bugs, auth bypasses, unnecessary re-renders, and runtime routing errors that typed routes would catch at compile time.** </critical_reminders>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.