Section 3 · Navigation and State
File-Based Routing with Expo Router
18 min3 min readFill in the blanks
Lesson at a glance
Turn files in src/app into Trailhead’s screens with Expo Router, wrap them in a native stack with a layout file, and move between them with Link and the router object.
- 1Files are routes
- 2Layouts wrap their siblings
- 3Moving between screens
- 4A stack is not browser history
- 5Not-found and why URLs still matter
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
On the web, a URL bar shows where you are, and the back button walks through history. On a phone, users see neither: they see screens sliding in from the right, a back arrow or swipe, and tabs at the bottom. Underneath, Expo Router still gives every screen a URL, which is what makes deep links, typed navigation and a web version possible. You get both models at once.
Files are routes
Expo Router builds your navigation from the files in src/app. The path of the file is the route:
src/app/
_layout.tsx wraps everything below in a navigator
index.tsx /
about.tsx /about
settings/
_layout.tsx wraps the settings screens
index.tsx /settings
units.tsx /settings/units
+not-found.tsx any URL that doesn't matchEach route file default-exports a component. That’s all a screen is:
import { Text, View } from 'react-native';
export default function About() {
return (
<View style={{ flex: 1, padding: 16 }}>
<Text>Trailhead keeps a private log of your hikes.</Text>
</View>
);
}Because every file in the folder becomes a route, keep everything else elsewhere: src/components, src/lib, src/state. A row component accidentally saved in src/app becomes a screen that anyone can deep-link to.
Layouts wrap their siblings
A _layout.tsx file renders a navigator around the routes in its folder. Trailhead’s root layout uses a Stack, the native push-and-pop navigator with platform animations, swipe-back on iOS and the system back gesture on Android:
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack screenOptions={{ headerTintColor: '#2f6f4e' }}>
<Stack.Screen name="index" options={{ title: 'Trailhead' }} />
<Stack.Screen name="about" options={{ title: 'About' }} />
</Stack>
);
}screenOptions applies to every screen in the stack; each Stack.Screen customizes one, matched by its route name. You don’t have to list every route: unlisted ones still work with default options.
A few header options cover most needs: title, headerShown: false for screens that draw their own top bar (like the full-screen map from the last lesson), headerBackTitle for the label next to the iOS back arrow, and headerRight for a button such as “Edit”. On iOS, headerLargeTitle: true gives you the big collapsing title from Apple’s own apps, which suits a top-level list like the hike log.
When a title depends on data (the hike’s name, say), render <Stack.Screen options={{ title: hike.name }} /> inside the screen itself. It renders nothing visible; it configures the header of the screen it’s in.
Moving between screens
For anything the user taps, use Link. It renders text by default; with asChild it hands navigation to your own pressable component:
import { Link } from 'expo-router';
import { Pressable, Text, View } from 'react-native';
export default function HikeLog() {
return (
<View style={{ flex: 1, padding: 16, gap: 12 }}>
<Link href="/about">About Trailhead</Link>
<Link href="/settings" asChild>
<Pressable accessibilityRole="button">
<Text>Settings</Text>
</Pressable>
</Link>
</View>
);
}From code, after a save or a login, use the router object:
import { router } from 'expo-router';
router.push('/settings/units'); // add a screen on top
router.back(); // pop the top screen
router.replace('/'); // swap the current screen; no way back to itpush is the normal forward move. replace is for flows where returning makes no sense: after onboarding, after logging in, after a “saved!” confirmation. back is what the header’s back arrow and the swipe gesture already do; call it yourself after a form saves so the user lands where they came from. There’s also router.navigate, which goes to a route and, if that route is already in the stack, returns to it instead of pushing a duplicate.
A stack is not browser history
It’s tempting to treat the stack like browser history, but there’s one difference that matters: every screen in the stack stays mounted. When you push the hike detail screen, the log underneath keeps its state and scroll position, and its effects keep running. That’s why going back is instant, and also why a screen’s useEffect doesn’t re-run when you return to it. When you need to refresh data every time a screen comes back into view, Expo Router provides useFocusEffect, which runs when the screen gains focus and cleans up when it loses it.
Not-found and why URLs still matter
+not-found.tsx renders for any URL that doesn’t match a route, which on mobile happens mostly through deep links from old emails or a renamed screen. Give it a friendly message and a Link home.
Thinking in URLs pays off later in this section: a notification can open /hikes/42 directly, a link in a message can open the app on the right screen, and the same routes run on the web if you ever build Trailhead for it.
Next you’ll add tabs at the bottom of the app, a detail screen for each hike, and pass the hike’s id through the URL.
Key takeaways
- Every file in
src/appis a route, and its path is the URL; keep components, hooks and utilities outside that folder. - A
_layout.tsxfile wraps its sibling routes in a navigator, such as a nativeStack. - Use
<Link href>for navigation the user taps, androuter.push,router.replaceandrouter.backfrom code. pushadds a screen to the stack,backpops it, andreplaceswaps the current screen so the user can’t go back to it.