Expo Router skill for Codex

Routes you can read straight from the file tree. Codex maps the app directory before touching it, keeps one navigator per layout, and gates signed-in screens with protected routes rather than scattered redirects. Params stay as validated IDs, typed routes catch broken links at compile time, and every advertised deep link is opened on a simulator from both cold and warm start before the work counts as finished.

  • Mobile & Desktop
  • $expo-router
  • 207 lines
  • 2 files
  • ~2,050 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- expo-router

Installs to ~/.agents/skills/expo-router/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $expo-router, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Structures navigation in Expo apps with Expo Router file-based routing - the app directory, _layout files with Stack and Tabs, route groups, dynamic and catch-all segments, typed routes, modals, protected routes for auth, and deep links from a custom scheme or universal link. Use when adding screens or tabs, passing params between routes, gating screens behind sign-in, fixing a deep link that opens the wrong screen, or migrating from React Navigation config to Expo Router.

Use it when

  • Adding screens, tabs, modals or nested stacks to an Expo app
  • Gating routes behind authentication
  • A custom-scheme or universal link lands on the wrong screen
  • Moving an app from React Navigation config to Expo Router

Use something else when

  • You need to set up apple-app-site-association or assetlinks.json on a domain: use mobile-deep-links
  • The work is app config, native modules or SDK upgrades: use react-native-expo

How to check the result

tsc passes with typed routes enabled, and each new deep link opens the intended screen via simctl or adb from cold and warm start with a working back button.

The mistake it prevents

Calling router.replace in the root layout's first render to redirect signed-out users, which throws because the navigator has not mounted yet.

~/.agents/skills/expo-router/SKILL.md

--- name: expo-router description: Structures navigation in Expo apps with Expo Router file-based routing - the app directory, _layout files with Stack and Tabs, route groups, dynamic… license: MIT ---

Done well means: the URL of every screen is predictable from the file tree, each layout owns exactly one navigator, auth gating happens in one place, and every deep link the app advertises has been opened on a simulator and lands on the right screen with a working back button.

Inspect first

cat AGENTS.md 2>/dev/null
grep -E '"expo-router"|"expo"' package.json
grep -E '"scheme"|typedRoutes|"origin"' app.json app.config.* 2>/dev/null
find app -name '*.tsx' -o -name '*.ts' | sort        # the route tree

Print the tree before editing; the file layout is the navigation config.

File conventions

FileURLNotes
app/_layout.tsxwraps everythingRoot providers, fonts, root Stack
app/index.tsx/
app/settings.tsx/settings
app/user/[id].tsx/user/42useLocalSearchParams<{ id: string }>()
app/docs/[...slug].tsx/docs/a/b/cslug is string[]
app/(tabs)/_layout.tsxno segmentGroup: organises files, adds no path
app/+not-found.tsxany unmatched URLAlways provide one
app/+native-intent.tsxn/aRewrite incoming native deep links before routing
app/api/hello+api.ts/api/helloServer route; needs web.output: "server" and a host

Groups let two files share a URL shape with different navigators, e.g. (auth)/login.tsx and (app)/(tabs)/home.tsx.

Layouts

// app/_layout.tsx
import { Stack } from 'expo-router';
import { SessionProvider, useSession } from '@/auth/session';

export const unstable_settings = { anchor: '(app)' }; // screen under a deep-linked modal

function RootNavigator() {
  const { session, isLoading } = useSession();
  if (isLoading) return null;                          // or keep the splash screen up
  return (
    <Stack screenOptions={{ headerShown: false }}>
      <Stack.Protected guard={!!session}>
        <Stack.Screen name="(app)" />
        <Stack.Screen name="modal" options={{ presentation: 'modal', headerShown: true }} />
      </Stack.Protected>
      <Stack.Protected guard={!session}>
        <Stack.Screen name="(auth)" />
      </Stack.Protected>
    </Stack>
  );
}

export default function RootLayout() {
  return (
    <SessionProvider>
      <RootNavigator />
    </SessionProvider>
  );
}
// app/(app)/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';

export default function TabsLayout() {
  return (
    <Tabs screenOptions={{ tabBarActiveTintColor: '#0a7' }}>
      <Tabs.Screen name="index" options={{ title: 'Home',
        tabBarIcon: ({ color, size }) => <Ionicons name="home" color={color} size={size} /> }} />
      <Tabs.Screen name="inbox" options={{ title: 'Inbox' }} />
    </Tabs>
  );
}

Rules:

  • Protected routes (Stack.Protected / Tabs.Protected, available in recent SDKs) are the default for auth. When the guard flips, the router redirects away from screens that became inaccessible. On older SDKs, use <Redirect href="/login" /> in a group layout.
  • Screens listed in a layout only configure options; files without an entry are still routable. Hide a tab with options={{ href: null }}.
  • Put the nested Stack for a tab inside that tab’s folder ((tabs)/inbox/_layout.tsx) so pushing a detail screen keeps the tab bar.
import { Link, router, useLocalSearchParams } from 'expo-router';

<Link href={{ pathname: '/user/[id]', params: { id: user.id } }}>Profile</Link>
router.push('/settings');
router.replace('/(app)/(tabs)');       // no back entry: use after login
router.dismissTo('/inbox');            // pop until a route, or push it if absent
if (router.canGoBack()) router.back();

const { id } = useLocalSearchParams<{ id: string }>();
NeedUse
Params of this screenuseLocalSearchParams (does not update when other screens change)
Params of the focused route anywhereuseGlobalSearchParams (re-renders on every navigation; use sparingly)
Current path for analyticsusePathname() + useEffect
Run code when a screen gains focususeFocusEffect(useCallback(() => {...}, []))
  • Params are strings (or string arrays). Parse and validate numbers, booleans and JSON. Pass IDs, never whole objects: anything in params ends up in the URL and in deep links.
  • Enable typed routes with "experiments": { "typedRoutes": true } in app config; the dev server generates the types so a mistyped href fails tsc.
  1. Set scheme in app config ("scheme": "acme"). Changing it needs a new native build.
  2. A link acme://user/42 resolves to app/user/[id].tsx with no extra code.
  3. For universal links / Android App Links, add ios.associatedDomains and android.intentFilters with autoVerify, and host the AASA and assetlinks.json files. Domain setup belongs to $mobile-deep-links.
  4. Rewrite legacy or third-party URL shapes in app/+native-intent.tsx:
export function redirectSystemPath({ path, initial }: { path: string; initial: boolean }) {
  try {
    const url = new URL(path, 'acme://app');
    if (url.pathname.startsWith('/p/')) return `/product/${url.pathname.slice(3)}`;
    return path;
  } catch {
    return '/';                                  // never throw here: the app would not open
  }
}

Test on simulators (the dev build must be installed):

npx uri-scheme open "acme://user/42" --ios
xcrun simctl openurl booted "acme://user/42"
adb shell am start -W -a android.intent.action.VIEW -d "acme://user/42" com.acme.app

Check both cold start (app killed) and warm start (app backgrounded). When a link deep inside a stack opens with no back button, set unstable_settings with an anchor or initialRouteName in that layout so a parent screen is inserted beneath it.

Migrating from React Navigation

  • Map each navigator to a folder with a _layout.tsx; each screen component becomes a file whose default export is the screen.
  • Replace navigation.navigate('Profile', { id }) with router.push({ pathname: '/user/[id]', params: { id } }). Replace route.params with useLocalSearchParams.
  • Delete the manual linking config: file paths are the linking config. Keep a list of previously published URLs and redirect old shapes in +native-intent.tsx.
  • Migrate one navigator at a time; the router is built on React Navigation, so screenOptions and most listeners carry over.

Gotchas

  • A route file must default-export a component. Helpers in app/ become routes: keep components, hooks and utils outside app/.
  • Two files resolving to the same URL ((a)/home.tsx and (b)/home.tsx) is ambiguous; the router warns and picks one. Keep URLs unique unless you mean shared routes.
  • Navigating during the first render of the root layout throws “navigate before mounting”. Use <Redirect> or protected routes instead of router.replace in a top-level effect.
  • Keep the splash screen visible (SplashScreen.preventAutoHideAsync()) until fonts and session load, or users see a flash of the login screen.
  • router.push to the current route with new params pushes a duplicate; use router.setParams to update in place.

Verification

npx tsc --noEmit                 # with typedRoutes, catches broken hrefs
npx expo start                   # open each changed route; then test the deep links above
npx expo export --platform web   # if web is a target: static routes must render

Done when every new route opens from the UI and from its deep link (cold and warm), the back button returns somewhere sensible, signed-out users cannot reach protected screens, and unknown URLs show +not-found.

Output: route map

When adding or restructuring routes, report the resulting map:

| URL | File | Navigator | Auth |
|---|---|---|---|
| / | app/(app)/(tabs)/index.tsx | Tabs > Stack | signed in |
| /user/[id] | app/(app)/user/[id].tsx | Stack | signed in |
| /login | app/(auth)/login.tsx | Stack | signed out |
Deep links tested: acme://user/42 (cold, warm), https://acme.app/p/9 (rewritten)

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Expo Router"
  short_description: "Routes you can read straight from the file tree"
  default_prompt: "$expo-router Add a tab bar with Home and Inbox, each with its own stack of detail screens"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem