Expo Router Patterns

SkillFiles & storage

File-based routing and navigation for Expo/React Native

Available today. Use it from your connected AI after setup.

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.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

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.


Core Patterns

Pattern 1: File Conventions

Every file in app/ maps to a route. Special characters change behavior:

FileURLPurpose
index.tsx/ (or parent path)Default route for directory
about.tsx/aboutStatic route
[id].tsx/:idDynamic segment
[...slug].tsx/a/b/cCatch-all segments
_layout.tsxN/AWraps sibling routes in navigator
(group)/Not in URLOrganizes routes without URL impact
+not-found.tsxN/A404 fallback for unmatched routes
+api.tsServer endpointAPI route handler
+html.tsxN/ARoot 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:


<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>

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