dist/plugins/mobile-navigation-expo-router/skills/mobile-navigation-expo-router/SKILL.md
File-based routing and navigation for Expo/React Native
npx skillsauth add agents-inc/skills mobile-navigation-expo-routerInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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>
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:
Key patterns covered:
_layout.tsx, [param], [...slug], (group)/, +api.ts, +not-found.tsxStack.Protected guard pattern (SDK 53+), redirect pattern (SDK 52)useRouter, useLocalSearchParams, useSegments, usePathnameWhen NOT to use:
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:
_layout.tsx files to create any navigation structure (tabs containing stacks containing modals)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.
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
_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
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
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
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
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
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
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 }));
}
</patterns>Full static rendering and Head examples: examples/web.md
Detailed Resources:
<decision_framework>
Expo Router provides multiple navigation patterns. The key decisions:
<Link> vs imperative router.push/replace/dismiss? See reference.md for the navigation method decision tree.<Slot />? See reference.md for the layout navigator selection guide.useLocalSearchParams vs useGlobalSearchParams, useRouter vs <Link>, useFocusEffect vs useEffect? See reference.md for the hook selection table.Quick rules:
<Link> for static navigation in UI, router.push for programmatic navigation in event handlersuseLocalSearchParams unless you specifically need background screen updatesuseFocusEffect instead of useEffect when data should refresh on screen focus</decision_framework>
<red_flags>
High Priority Issues:
useGlobalSearchParams when useLocalSearchParams works -- global causes re-renders on ALL route changes, even when screen is in background; use local for screen-specific paramsStack.Protected with guard prop instead; it's declarative, handles edge cases, and integrates with deep linking correctly_layout.tsx in route groups -- without a layout, the default Stack has default headers and no control over transitions; always define layouts explicitlyMedium Priority Issues:
name prop mismatch in layout screens -- Stack.Screen name="tabs" does not match directory (tabs)/; must be name="(tabs)" exactlypresentation: "modal" in parent layout -- configuring modal in the modal file's own layout does nothing; modals must be configured in the parent navigatorrouter.replace in initial render -- causes navigation before the navigator is ready; use Redirect component or useFocusEffect insteadGotchas & Edge Cases:
Stack.Protected redirects to the first unprotected screen -- deep link target is lost unless you store and replay it after auth[...slug] params: Always an array, but useLocalSearchParams may return a string if only one segment; always normalize with Array.isArray(slug) ? slug : [slug]backBehavior: "history" in Tabs layout to preserve stack per tab+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 requiredgenerateStaticParams runs in Node.js -- no access to React Native APIs, browser APIs, or native modules+api.web.ts is invalid), bundles to CommonJSnpx expo customize tsconfig.jsonexport default -- Expo Router discovers screens via default exports; this overrides project "named exports only" conventions for files in app/</red_flags>
<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>
development
Xquik REST API patterns for X post search, user and timeline reads, cursor pagination, media downloads, monitors, signed webhooks, and approval-gated X actions
development
Xquik REST API patterns for X post search, user and timeline reads, cursor pagination, media downloads, monitors, signed webhooks, and approval-gated X actions
development
Mapbox GL JS interactive maps - map initialization, markers, popups, sources, layers, expressions, clustering, 3D terrain, geocoding, directions
tools
Leaflet interactive maps - map setup, tile layers, markers, popups, GeoJSON, custom controls, plugins, clustering, events