Section 3 · Navigation and State
Modals, Protected Routes and Deep Links
17 min3 min readCode playground
Lesson at a glance
Present Trailhead’s add-hike form as a modal, keep signed-out users on the sign-in screen with Stack.Protected, and open specific hikes from deep links, knowing how URLs are matched to route files.
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Three situations don’t fit the push-a-screen pattern you’ve used so far. Logging a hike is a short task the user starts and finishes, so it should feel separate from browsing. Some screens should exist only for signed-in users. And Trailhead should open on the right hike when someone taps a link in a reminder or a message. Expo Router handles all three in the layout file.
The add-hike form as a modal
A modal is a screen presented over the current context, usually sliding up from the bottom. On iOS it appears as a card with the previous screen visible behind it, and users can swipe it down to cancel. Make the form a modal by setting its presentation in the root Stack:
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="hikes/[id]" options={{ title: 'Hike' }} />
<Stack.Screen name="new-hike" options={{ presentation: 'modal', title: 'Log a hike' }} />
</Stack>Opening it is still router.push('/new-hike') (or a Link); closing it after a save is router.back(), which pops the modal like any screen. Android has no card-style sheet by default, so the modal appears as a full-screen page with a different animation; give the form a visible Cancel button in headerLeft so both platforms have an obvious way out.
When the form has unsaved input, swiping it away loses the user’s typing. For long forms, confirm before discarding with an Alert on Cancel; for a short form like Trailhead’s, a draft that’s quick to retype is an acceptable trade.
Protected routes
Trailhead syncs hikes to an account, so most screens require signing in. Stack.Protected wraps routes in a condition:
import { Stack } from 'expo-router';
import { useSession } from '../state/session';
export default function RootLayout() {
const { isSignedIn, isLoading } = useSession();
if (isLoading) return null; // the splash screen is still showing
return (
<Stack>
<Stack.Protected guard={!isSignedIn}>
<Stack.Screen name="sign-in" options={{ headerShown: false }} />
</Stack.Protected>
<Stack.Protected guard={isSignedIn}>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="hikes/[id]" options={{ title: 'Hike' }} />
<Stack.Screen name="new-hike" options={{ presentation: 'modal', title: 'Log a hike' }} />
</Stack.Protected>
</Stack>
);
}When guard is false, those routes are unavailable: navigating to them, including from a deep link, sends the user to the first available screen instead, and any history entries for them are removed. Signing in flips isSignedIn, the layout re-renders, and the protected screens appear without you calling router at all. Signing out does the reverse, which also clears the back stack, so the back gesture can’t reveal a signed-in screen.
useSession is a context like the one you built two lessons ago. Its isLoading covers the moment at startup when Trailhead is still reading the saved token from secure storage (section 4); rendering nothing while the splash screen is up avoids a flash of the sign-in page for users who are already signed in.
Deep links
A deep link opens the app on a specific screen. Give Trailhead a URL scheme in app.json:
{
"expo": {
"name": "Trailhead",
"scheme": "trailhead"
}
}Now trailhead://hikes/42 opens the app and renders /hikes/42 with the same Stack you built, including a back button to the log. You get this for free from file-based routing: every route already has a URL. Schemes are part of the native app configuration, so test them in a development build; Expo Go has its own scheme. From a terminal:
npx uri-scheme open trailhead://hikes/42 --ios
npx uri-scheme open trailhead://hikes/42 --androidCustom schemes have a weakness: any app can claim trailhead://. For links you share on the web, use universal links (iOS) and App Links (Android), which use your own https:// domain and require hosting a small verification file on it. Expo’s linking guide walks through both.
How a URL picks a route
When a link arrives, Expo Router compares its path with every route file. Knowing the rules removes the guesswork:
| Rule | Example |
|---|---|
Groups like (tabs) add no segment |
(tabs)/stats.tsx matches /stats |
index adds no segment |
settings/index.tsx matches /settings |
[name] matches exactly one segment |
hikes/[id].tsx matches /hikes/42 |
[...name] matches one or more remaining segments, as an array |
trails/[...path].tsx matches /trails/alps/mont-blanc |
| Static beats dynamic beats catch-all | /hikes/new picks hikes/new.tsx over hikes/[id].tsx |
| The query string becomes extra params | /hikes/42?units=mi adds units |
That ranking is why you can safely add hikes/new.tsx next to hikes/[id].tsx. In the exercise you’ll implement this matcher yourself, which is the fastest way to never be surprised by it again.
That completes navigation. Section 4 connects Trailhead to the outside world: a server, the device’s storage, and its sensors.
Key takeaways
- A Stack screen with
presentation: 'modal'slides up as a sheet on iOS; dismiss it withrouter.back()after saving. Stack.Protectedwith aguardhides routes when the condition is false, so signed-out users can’t reach them, even from a deep link.- A
schemein app.json makestrailhead://hikes/42open the/hikes/[id]route; custom schemes need a development build, not Expo Go. - When matching a URL, groups and
indexadd no segment, static segments beat dynamic ones, and[...rest]catches whatever is left.