Expo Router Patterns
SkillFiles & storageFile-based routing and navigation for Expo/React Native
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Expo Router Patterns skill
What this skill tells your AI
The instructions your AI receives, as published by agents-inc/skills in src/skills/mobile-navigation-expo-router/SKILL.md and read by ahel’s review.
Quick Guide: File-based routing for React Native and web. Files in
app/become routes automatically. Use_layout.tsxfor navigation structure (Stack, Tabs), groups(name)/for URL-invisible organization,[param]for dynamic segments. SDK 53+: useStack.Protectedwith aguardprop for authentication. EnabletypedRoutesfor compile-time route safety. API routes use+api.tssuffix.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST define navigation structure in _layout.tsx files -- screens without a layout parent default to a basic Stack)
(You MUST use Stack.Protected with guard prop for authentication flows in SDK 53+ -- NOT imperative redirects in useEffect)
(You MUST use useLocalSearchParams for route params in screens -- useGlobalSearchParams causes unnecessary re-renders on unfocused screens)
(You MUST enable typedRoutes in app.json experiments for compile-time route validation -- catches invalid navigation at build time)
</critical_requirements>
Auto-detection: expo-router, Expo Router, file-based routing, _layout.tsx, Stack.Screen, Tabs.Screen, useRouter, useLocalSearchParams, useSegments, usePathname, Link href, router.push, router.replace, router.dismiss, router.dismissTo, +api.ts, +not-found, Stack.Protected, generateStaticParams, expo-router/head, Slot, Redirect, useFocusEffect, NativeTabs, headless tabs, TabSlot, TabTrigger
When to use:
- Setting up file-based navigation in an Expo app
- Implementing authentication flows with route protection
- Creating tab, stack, or modal navigation layouts
- Building API routes for server-side logic
- Configuring typed routes for compile-time safety
- Adding deep linking and static rendering for web
Key patterns covered:
- File convention:
_layout.tsx,[param],[...slug],(group)/,+api.ts,+not-found.tsx - Layout navigators: Stack, Tabs, headless tabs, native tabs
- Authentication:
Stack.Protectedguard pattern (SDK 53+), redirect pattern (SDK 52) - Navigation hooks:
useRouter,useLocalSearchParams,useSegments,usePathname - API routes with standard Request/Response
- Typed routes with auto-generated TypeScript definitions
- Modal routes, shared routes between tabs, nested navigation
When NOT to use:
- Apps that need fully custom native navigation controllers beyond what React Navigation provides
- Simple single-screen apps with no navigation
- Web-only projects where a web-native router is more appropriate
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_framework>
Decision Frameworks
Expo Router provides multiple navigation patterns. The key decisions:
- Route type -- static, dynamic, catch-all, grouped, API? See reference.md for the full route type decision tree.
- Navigation method -- declarative
<Link>vs imperativerouter.push/replace/dismiss? See reference.md for the navigation method decision tree. - Layout navigator -- Stack, Tabs, NativeTabs, headless tabs, or
<Slot />? See reference.md for the layout navigator selection guide. - Hook choice --
useLocalSearchParamsvsuseGlobalSearchParams,useRoutervs<Link>,useFocusEffectvsuseEffect? See reference.md for the hook selection table.
Quick rules:
- Prefer
<Link>for static navigation in UI,router.pushfor programmatic navigation in event handlers - Always use
useLocalSearchParamsunless you specifically need background screen updates - Use
useFocusEffectinstead ofuseEffectwhen data should refresh on screen focus
</decision_framework>
<red_flags>
RED FLAGS
High Priority Issues:
- Using
useGlobalSearchParamswhenuseLocalSearchParamsworks -- global causes re-renders on ALL route changes, even when screen is in background; use local for screen-specific params - Imperative redirects in useEffect for auth (SDK 53+) -- use
Stack.Protectedwithguardprop instead; it's declarative, handles edge cases, and integrates with deep linking correctly - Missing
_layout.tsxin route groups -- without a layout, the default Stack has default headers and no control over transitions; always define layouts explicitly - Storing secrets in API route responses without authentication -- API routes are public endpoints; validate authentication tokens before returning sensitive data
Medium Priority Issues:
nameprop mismatch in layout screens --Stack.Screen name="tabs"does not match directory(tabs)/; must bename="(tabs)"exactly- Not using
presentation: "modal"in parent layout -- configuring modal in the modal file's own layout does nothing; modals must be configured in the parent navigator - Calling
router.replacein initial render -- causes navigation before the navigator is ready; useRedirectcomponent oruseFocusEffectinstead
Gotchas & Edge Cases:
- Deep links to protected routes:
Stack.Protectedredirects to the first unprotected screen -- deep link target is lost unless you store and replay it after auth - Catch-all
[...slug]params: Always an array, butuseLocalSearchParamsmay return a string if only one segment; always normalize withArray.isArray(slug) ? slug : [slug] - Tab groups reset on tab switch: By default, switching tabs resets the tab's stack; use
backBehavior: "history"in Tabs layout to preserve stack per tab - Android 5-tab limit: Material Design constrains bottom tabs to 5; native tabs enforce this
+not-found.tsxonly catches at its directory level -- a+not-found.tsxinapp/won't catch 404s insideapp/docs/; each directory needs its own if required- Static rendering
generateStaticParamsruns in Node.js -- no access to React Native APIs, browser APIs, or native modules - API route limitation: No dynamic imports, no platform-specific extensions (
+api.web.tsis invalid), bundles to CommonJS - Typed routes are git-ignored -- CI pipelines fail type checks unless types are regenerated with
npx expo customize tsconfig.json - Route files require
export default-- Expo Router discovers screens via default exports; this overrides project "named exports only" conventions for files inapp/
</red_flags>
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md
(You MUST define navigation structure in _layout.tsx files -- screens without a layout parent default to a basic Stack)
(You MUST use Stack.Protected with guard prop for authentication flows in SDK 53+ -- NOT imperative redirects in useEffect)
(You MUST use useLocalSearchParams for route params in screens -- useGlobalSearchParams causes unnecessary re-renders on unfocused screens)
(You MUST enable typedRoutes in app.json experiments for compile-time route validation -- catches invalid navigation at build time)
Failure to follow these rules will cause navigation bugs, auth bypasses, unnecessary re-renders, and runtime routing errors that typed routes would catch at compile time.
</critical_reminders>
Signals
- GitHub stars
- 24
- Forks
- 8
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
mobile-navigation-expo-router- Source
- github.com/agents-inc/skills