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: 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)
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
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:
- URLs are first-class -- every screen has a URL, enabling deep linking on mobile and SEO on web without extra configuration
- Layouts are composable -- nest
_layout.tsxfiles to create any navigation structure (tabs containing stacks containing modals) - The file tree IS the sitemap -- new developers understand navigation by reading the directory structure, not a central config
- 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.
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
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).
// 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
Pattern 3: Navigation Hooks
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
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.
// 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 Legacy redirect pattern (SDK 52): examples/auth.md
Pattern 5: Modal Routes
Modals are defined as regular route files but configured with presentation: "modal" in the parent layout.
// 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
Pattern 6: API Routes
Files with +api.ts suffix define server-side endpoints. They use standard Web Request/Response APIs.
// 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
Pattern 7: Typed Routes
Enable compile-time route validation by setting experiments.typedRoutes: true in app.json. The dev server auto-generates type definitions.
// 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
Pattern 8: Static Rendering and Head Metadata (Web)
Static rendering generates HTML at build time for SEO and fast initial loads.
// 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:
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({ id: post.id }));
}
Full static rendering and Head examples: examples/web.md
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 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
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/
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.