Claude Skill

mobile-navigation-expo-router

File-based routing and navigation for Expo/React Native

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download agents-inc-skills-dist_plugins_mobile-navigation-expo-router_skills_mobile-navigation-expo-router-3a51ef5.zip · 17 KB
Part of agents-inc/skills — 130 skills

Install

skills CLI npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/mobile-navigation-expo-router/skills/mobile-navigation-expo-router
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
Git 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.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



Detailed Resources:


<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 for the full route type decision tree.
  2. Navigation method -- declarative <Link> vs imperative router.push/replace/dismiss? See reference.md for the navigation method decision tree.
  3. Layout navigator -- Stack, Tabs, NativeTabs, headless tabs, or <Slot />? See reference.md for the layout navigator selection guide.
  4. Hook choice -- useLocalSearchParams vs useGlobalSearchParams, useRouter vs <Link>, useFocusEffect vs useEffect? See 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>

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.

No comments yet.

Reviews (0)

No reviews yet.

Related