An Expo app has one navigation tree, built by Expo Router from the files in src/app/, whose routes
equal a repo-owned route contract (contracts/navigation.json): flows switch by guards, hrefs and
params are checked, screens with unsaved work cannot be lost by a stray Back, and every URL lands
somewhere. React Navigation 7's static API is the fenced alternative. A script proves it.
Rules that must hold
- One router, one tree: Expo Router's. The root
src/app/_layout.tsxdefault-exports a layout that renders the root navigator, directly or through the components it renders (app-startup-and-bootstrap's root skeleton:AppRoot>RootStack>AppStack). NoNavigationContainer,createStaticNavigationor@react-navigation/*import beside it: expo-router 57 carries its own React Navigation, and a second copy has its own contexts and its own back stack. (root-layout,one-navigator) - The contract is the route table. Every route file (route id = its path under
src/appwithout the extension) is incontracts/navigation.jsonroutes, and every contract route has a file;homenames the first screen. A route change starts in the contract, so a stray file (Expo Router bundles everything insrc/app) or a forgotten screen is caught. (contract,route-table,home-route) - Flows switch by guards, never by navigating. Signed out / signed in, onboarding / main and
test-build-only screens are
Stack.Protectedgroups whoseguardreads a store selector, each in the contract (protected). No<Redirect>in a layout and norouter.*call from a layout's effect: they render the wrong screen first and leave it in the history, while a guard turning false removes its screens from the history. Guards hide screens; the server still checks data. (protected-guard,flow-by-guard) - Hrefs are typed.
experiments.typedRoutesis on (CI generates the types withnpx expo customize tsconfig.jsonbeforetsc), and app code never writesas Href: a renamed route then failstscwherever it is linked. (typed-routes,href-cast) - Params are parsed at the edge. Any URL can open a dynamic route with any text, and
useLocalSearchParams<T>()is a cast. Each dynamic route has a valibot parser returning aResult(paramParsers), the model shows the screen's not-found state on failure, and params stay ids and short plain values. (params-parsed) - A screen with unsaved work blocks its removal. Its model hook calls
usePreventRemove(the template'suseDiscardGuard), which sees the header back button, the iOS swipe, a sheet's swipe down, Android's back button and predictive back gesture, androuter.back(); Back asks before discarding. The contract lists it inguarded. Turn the iOS swipe off (gestureEnabledandfullScreenGestureEnabledfalse,"gesture": "off") only where starting it already harms. The guard cannot see a kill: save drafts as they change. (guarded-prevent-remove,guarded-gesture-off) - Going back never stacks a second home.
router.navigateandpush(and<Link>) push, so the home route is reached withrouter.dismissTo(home)orrouter.back()(<Link dismissTo>). (go-back) - A destination is a route; a layer over a live screen is not. Modal destinations are routes with
a modal
presentation, exactly the contract'smodals(on Androidmodalopens like a push,formSheetis a bottom sheet). Confirmations, the discard dialog and a pause layer render inside the screen or the dialog host, because a route change would cover or unmount what they sit on. (modal-presentation) - Every URL lands somewhere.
src/app/+not-found.tsxrenders for an unknown URL (without it the router shows its development page); its one action isdismissTo('/'), so Back cannot return to the dead link, and it never prints the URL. (not-found) - The native stack.
Stackfromexpo-router(react-native-screens): the platform draws the transitions, swipe and header. The JavaScript stack only with"jsStack": trueand a reason. Every<Stack>takes its animation from motion-and-animation'suseScreenAnimation(). (native-stack) - Navigation lives in screens' model hooks.
src/ui,src/stores,src/services,src/data,src/apiandsrc/corenever import a router: components takeonPress, stores change state. The one exception is a root kit (src/services/<concern>/<concern>-root-kit.tsx, registered in app-startup-and-bootstrap'screateRootKits), whose effects route notification taps beside the root Stack. Navigation state is never persisted; an open editor is reopened from its saved draft. (nav-in-screens) - Debug routes are gated and guarded. A test-build screen renders only through
TEST_ONLY(debug-menu-and-test-hooks' gate, so production bundles drop it) and sits in a protected group, so a production deep link cannot open it. (debug-gated) - Placeholders are listed and never ship. While screens are built one by one, an unbuilt route
renders the placeholder screen, so the route table, its typed hrefs and every model hook's
navigation exist from day one (no no-op handlers); the contract lists them and
--releasefails while any is left. (placeholder-route,placeholder-release) - With React Navigation 7 instead (appendix): one static config whose groups (
ifhooks) equal the contract'sgroups, onecreateStaticNavigationcontainer (never<NavigationContainer>),RootParamListfromStaticParamList, plain-data params,popToto go back, noreset()to switch flows, and the stack wrapped with.with()so pushes cross-fade under reduce motion. (group-if,static-container,typed-routes,params-serialisable,flow-by-guard,motion-transition)
Workflow
-
Read what the task needs in references/expo-router-in-practice.md:
Task Section first setup, the root layout, reopening work after a kill "The root layout and the startup order" a new screen, a tab, a header "File routes", "Layouts: one navigator tree" a link or a param "Typed routes and hrefs", "Params: parse them at the edge" sign-in, onboarding, test-only screens "Flows: Stack.Protected, never a redirect", "Debug routes and placeholders" a modal, a sheet, a dialog "Modals and what is not a route" Back, the swipe, Android back, unsaved changes "Back handling (iOS and Android)", "Going back to a screen already in the stack" a route test "Testing navigation" With React Navigation instead of Expo Router, read references/react-navigation-static.md and imitate
examples/react-navigation/. -
Set up once. Copy
templates/contracts/navigation.json(and its schema) and list the app's routes; copytemplates/src/app/+not-found.tsxwithtemplates/src/features/not-found/and add thenot-found.*keys oftemplates/src/i18n/catalogs/to every catalog (i18n-strings-and-catalogs). Turn onexperiments.typedRoutesinapp.config.ts. The root layout andAppStackcome from app-startup-and-bootstrap's root skeleton; add the rootStack.Protectedgroups insideAppStack. -
Add a route: add its id to the contract, create the thin route file rendering the feature's screen (architecture-and-boundaries), add its
Stack.Screenin the layout only for options (a title,presentation), and put its navigation calls in the feature's model hook. -
A dynamic route: write
src/features/<feature>/<feature>-params.tsaftertemplates/src/features/task-detail/task-detail-params.ts(with its test), map it inparamParsers, and render the not-found state on a parse failure. -
A flow: wrap its screens in
<Stack.Protected guard={...}>fed by a store selector, add the group toprotected, and test it the waytemplates/src/features/root-navigator/protected-routes.test.tsxdoes (copy that test when the app adds its first flow; it also pins the router facts). -
A screen with unsaved work: copy
templates/src/features/task-editor/use-discard-guard.tswith both tests, call it from the model hook with the "unsaved" boolean, render the dialog whileisPromptOpen, leave after a save throughleave(), and list the route inguarded. -
Run the tests, paths first (
--selectProjectstakes every following word as a project):npx jest src/features/not-found src/features/root-navigator src/features/task-editor src/features/task-detail --selectProjects unit android, thennpm run typecheckand lint as quality-gates requires. -
Run the checker:
node ${CLAUDE_SKILL_DIR}/scripts/check-navigation.mjs .; fix everyFAILline (file, rule, fix) and rerun untilRESULT: PASS. Rules and contract keys: references/contract-and-checker.md. Before a release, run it with--release.
Definition of done
-
contracts/navigation.jsonlists exactly the app's routes,home, every protected group, every guarded route with its hook, every modal, a parser for every dynamic route, the debug routes, and the placeholders (none before a release). - The root layout renders the one Expo Router tree in the canonical startup order; no hand-built
container and no
@react-navigation/*import. - Flows are
Stack.Protectedgroups fed by stores; no layout redirects or navigates from an effect. - Typed routes are on and generated in CI; no
as Href; dynamic routes parse their params with valibot. - Every guarded screen asks before discarding on iOS (button, swipe, sheet) and Android (back
button, gesture); its tests pass (
use-discard-guard.test.tsx,use-discard-guard.android.test.tsx). - The not-found route and its test pass (
not-found-navigation.test.tsx). - Before a release:
node ${CLAUDE_SKILL_DIR}/scripts/check-navigation.mjs . --releaseprintsRESULT: PASS. -
node ${CLAUDE_SKILL_DIR}/scripts/check-navigation.mjs .printsRESULT: PASS(no SKIP lines: every target is this skill's own).
Anti-patterns
- Deciding the first screen in the root layout with
router.replacein an effect, or a<Redirect>in a layout. UseStack.Protected; the guard decides before anything renders. router.navigate('/')or<Link href="/">to go home. It stacks a second home; usedismissTo.- Trusting
useLocalSearchParams<{ id: string }>(). Repeated keys arrive as arrays and links carry anything; parse with valibot. - Passing a whole object through params (
params: { task: JSON.stringify(task) }). Pass the id; the screen reads the task from its store or query. - A
BackHandlerlistener as the unsaved-changes guard. It misses the iOS swipe, the header button and code; useusePreventRemove. - Turning off the swipe on every screen "to be safe". People rely on it; guard the screens with work instead, and turn it off only where starting it harms.
- A modal route for a confirmation or a pause layer. It covers or unmounts the screen beneath; render it in the screen or the dialog host.
- Importing
@react-navigation/nativein an Expo Router app foruseIsFocusedor a theme. Import them fromexpo-router. - A debug route that re-exports the debug screen. It ships in production; render
TEST_ONLY?.DebugScreenbehind a guard. - No-op handlers for screens not built yet. Route them to the placeholder and list them.
useNavigationorrouterin asrc/uicomponent. PassonPressfrom the screen.- Persisting navigation state to reopen where the person was. Reopen from saved data instead; a stale state breaks after any route change.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/expo-router-in-practice.md | Routes, the root tree (owned by app-startup-and-bootstrap), reopening work, layouts, typed hrefs, params, flows, modals, back on iOS and Android, not-found, debug routes, placeholders, testing, verified facts, pitfalls | Workflow step 1 |
| references/contract-and-checker.md | Every contract key, every checker rule with its fix, configuration, multi-package | Workflow steps 2 and 8 |
| references/react-navigation-static.md | Appendix: React Navigation 7 static API, the mapping from Expo Router, tests, versions | Only for a React Navigation app |
templates/contracts/navigation.json |
The route contract, filled for the starting app | Workflow step 2 |
templates/contracts/navigation.schema.json |
Its JSON schema | Workflow step 2 |
templates/src/app/+not-found.tsx |
The not-found route (thin) | Workflow step 2 |
templates/src/features/not-found/ |
Its pure view and model hook (dismissTo('/')), each with its test, and the route test, which also pins dismissTo vs navigate |
Workflow step 2 |
templates/src/i18n/catalogs/ |
The not-found.* keys in en and ar, merged into the app's catalogs |
Workflow step 2 |
templates/src/features/task-detail/ |
A route params parser (valibot, Result) and its test |
Workflow step 4 |
templates/src/features/root-navigator/protected-routes.test.tsx |
Flow tests with Stack.Protected; pins the guard facts |
Workflow step 5 |
templates/src/features/task-editor/ |
useDiscardGuard (usePreventRemove) and its iOS and Android tests |
Workflow step 6 |
examples/placeholder-screen.tsx |
The placeholder screen shape for routes not built yet | When building screen by screen |
examples/react-navigation/ |
The static API shapes: stack, guards, container, types, params, debug route, guarded model, contract | Only for a React Navigation app |
scripts/check-navigation.mjs |
Checker for every rule above, both routers | Workflow step 8, and before a release with --release |
scripts/selftest.mjs |
Proves the checker passes both good apps and catches each planted bug | After changing the checker |
scripts/check-lib.mjs |
Shared script library (synced; do not edit here) | Never by hand |
scripts/lib/source-scan.mjs |
Shared lexer, JSX and import-resolution helpers (synced) | Never by hand |
scripts/lib/object-literal.mjs |
Shared reader for the static config object (synced) | Never by hand |
assets/shared.json |
Declares the synced helpers | When adding a shared file |
tests/bases/ |
The two fixture apps: Expo Router (app/) and React Navigation (react-navigation/) |
When adding a rule |
tests/fixtures/ |
The self-test cases per router: good, planted bugs, NOT APPLICABLE, bad input | When adding a rule |
Related skills
architecture-and-boundaries- thin routes, where route tests live, the test-only gate's imports.react-components-and-hooks- the model hook that holds navigation;ScreenFrameedges.motion-and-animation-useScreenAnimation()for every<Stack>.rtl-and-direction- the navigator's layout direction and the restart.design-system-structure- the navigation theme.app-startup-and-bootstrap- the root skeleton (_layout.tsx,AppRoot,RootStack,AppStack), the startup order, the splash and the draft flush that "Continue editing" reads.auth-and-secure-storage- the session store a sign-in guard reads.deep-links-and-universal-links- schemes, universal and app links,+native-intent.ui-states-and-feedback- the dialog host and not-found states inside screens.screen-contracts- what each screen shows.debug-menu-and-test-hooks- theTEST_ONLYgate and debug deep links.unit-and-component-tests-renderRouterWithProvidersand the Jest projects.e2e-maestro- the real swipe and back gesture on named devices.canvas-gestures-and-hit-testing- turning the back swipe off over a canvas.expo-router(Expo's official skill) - the Expo Router API reference.