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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- expo-router Installs to ~/.agents/skills/expo-router/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install expo-router The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project expo-router Installs to .agents/skills/expo-router/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/expo-router.zipunzip -o expo-router.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) expo-router PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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
| File | URL | Notes |
|---|---|---|
app/_layout.tsx | wraps everything | Root providers, fonts, root Stack |
app/index.tsx | / | |
app/settings.tsx | /settings | |
app/user/[id].tsx | /user/42 | useLocalSearchParams<{ id: string }>() |
app/docs/[...slug].tsx | /docs/a/b/c | slug is string[] |
app/(tabs)/_layout.tsx | no segment | Group: organises files, adds no path |
app/+not-found.tsx | any unmatched URL | Always provide one |
app/+native-intent.tsx | n/a | Rewrite incoming native deep links before routing |
app/api/hello+api.ts | /api/hello | Server 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
Stackfor a tab inside that tab’s folder ((tabs)/inbox/_layout.tsx) so pushing a detail screen keeps the tab bar.
Navigating and params
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 }>();
| Need | Use |
|---|---|
| Params of this screen | useLocalSearchParams (does not update when other screens change) |
| Params of the focused route anywhere | useGlobalSearchParams (re-renders on every navigation; use sparingly) |
| Current path for analytics | usePathname() + useEffect |
| Run code when a screen gains focus | useFocusEffect(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 mistypedhreffailstsc.
Deep links
- Set
schemein app config ("scheme": "acme"). Changing it needs a new native build. - A link
acme://user/42resolves toapp/user/[id].tsxwith no extra code. - For universal links / Android App Links, add
ios.associatedDomainsandandroid.intentFilterswithautoVerify, and host the AASA andassetlinks.jsonfiles. Domain setup belongs to$mobile-deep-links. - 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 })withrouter.push({ pathname: '/user/[id]', params: { id } }). Replaceroute.paramswithuseLocalSearchParams. - Delete the manual
linkingconfig: 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
screenOptionsand most listeners carry over.
Gotchas
- A route file must default-export a component. Helpers in
app/become routes: keep components, hooks and utils outsideapp/. - Two files resolving to the same URL (
(a)/home.tsxand(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 ofrouter.replacein 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.pushto the current route with new params pushes a duplicate; userouter.setParamsto 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.yaml
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.