mobile-navigation-expo-router · git:20260709.eeb9e6c · 2026-07-09 · sha256 44ade4110cc1b739
mobile-navigation-expo-router git:20260709.eeb9e6cA
Immutable. This exact content is served forever at /api/v1/blob/44ade4110cc1b739.
---
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>