03 · Route Params, Deep Links & Typed Routes¶
Every Expo Router screen has a URL. That one fact gives you three features: passing data between screens (params in the URL), opening any screen from outside the app (deep links), and type-safe navigation (the router knows every valid URL at build time). This lesson covers all three, plus the judgement call of what belongs in a URL.
Dynamic segments and search params¶
app/habit/[id].tsx → /habit/42
app/habit/[id]/history.tsx → /habit/42/history
app/tag/[...path].tsx → /tag/health/sleep (catch-all: path = ['health', 'sleep'])
Read them with useLocalSearchParams:
import { useLocalSearchParams } from 'expo-router';
export default function HabitHistory() {
const { id, range } = useLocalSearchParams<{ id: string; range?: '7d' | '30d' }>();
// /habit/42/history?range=30d → id = '42', range = '30d'
}
Dynamic segments ([id]) and query-string values (?range=30d) arrive in the same object. Navigate
with either a string or an object:
router.push(`/habit/${id}/history?range=30d`);
router.push({ pathname: '/habit/[id]/history', params: { id, range: '30d' } });
The object form handles URL encoding for you — use it whenever a value comes from user input.
Params are strings
Everything in a URL is a string. params: { count: 3 } arrives as '3'. The type parameter on
useLocalSearchParams<…>() is a promise you make to TypeScript, not a runtime check — parse
and validate values you depend on.
const params = useLocalSearchParams<{ id: string; range?: string }>();
const range = params.range === '30d' ? '30d' : '7d'; // default anything unexpected
const id = Number.parseInt(params.id, 10);
if (!Number.isFinite(id)) return <NotFound />;
Local vs global params¶
useLocalSearchParams returns params for this screen's route and only updates when this screen
is focused. useGlobalSearchParams returns params for whichever route is currently focused anywhere
in the app, and re-renders on every navigation. Screens stay mounted in stacks (lesson 1), so with
global params a background screen re-renders whenever the user navigates — use local params unless
you have a specific reason.
What belongs in a URL¶
Put identifiers and view state in params: an ID, a tab, a filter, a sort order. Don't put objects in params (a whole habit serialised to JSON): it bloats URLs, goes stale, and breaks when the screen is opened from a deep link that doesn't have the object. Pass the ID; have the detail screen read the habit from your store or cache (lessons 4 and 5).
Deep links¶
Custom scheme links¶
The scheme in app.json ("scheme": "habitapp") registers a URL scheme with the OS. Then
habitapp://habit/42 opens the app on that route. Test it without writing code:
# iOS Simulator
xcrun simctl openurl booted "habitapp://habit/42"
# Android emulator or USB device
adb shell am start -W -a android.intent.action.VIEW -d "habitapp://habit/42"
# Either, via the Expo CLI helper (prints the right command for Expo Go vs a dev build)
npx uri-scheme open "habitapp://habit/42" --ios
In Expo Go, your app runs inside Expo Go's own scheme (exp://…), so custom-scheme links to your
app need a development build (Level 3, lesson 9). Links you build in code with
Linking.createURL('/habit/42') (from expo-linking) produce the right form for whichever
environment you're running in.
Universal links / App Links¶
Custom schemes have weaknesses: any app can claim the same scheme, and they don't work if the app
isn't installed. Universal links (iOS) and Android App Links use ordinary https:// URLs
on a domain you own. If the app is installed, the OS opens it; otherwise the link opens your
website. Setting them up requires:
- Hosting verification files on your domain:
https://example.com/.well-known/apple-app-site-association(iOS) andhttps://example.com/.well-known/assetlinks.json(Android, containing your signing certificate's SHA-256 fingerprint). - Declaring the domain in
app.json:
{
"expo": {
"ios": { "associatedDomains": ["applinks:example.com"] },
"android": {
"intentFilters": [{
"action": "VIEW",
"autoVerify": true,
"data": [{ "scheme": "https", "host": "example.com", "pathPrefix": "/habit" }],
"category": ["BROWSABLE", "DEFAULT"]
}]
}
}
}
This needs your own domain, an Apple Developer team ID and a signed build, so it can't be done in
Expo Go. With Expo Router, once the OS hands your app https://example.com/habit/42, the path
/habit/42 is matched against your routes exactly like an internal link.
Typed routes¶
Turn on typed routes in app.json:
When you run npx expo start, Expo generates a type declaration listing every route, and href
props and router.push become type-checked:
router.push('/habit/42'); // ✅
router.push('/habits/42'); // ❌ type error: no such route
router.push({ pathname: '/habit/[id]', params: { id: '42' } }); // ✅
router.push({ pathname: '/habit/[id]', params: {} }); // ❌ missing id
Renaming a route file then turns every stale link in the codebase into a compile error rather than a "page not found" in production.
Worked example: a shareable, validated detail route¶
import { Link, Stack, useLocalSearchParams } from 'expo-router';
import { Share, Pressable, Text, View } from 'react-native';
import * as Linking from 'expo-linking';
const RANGES = ['7d', '30d', '365d'] as const;
type Range = (typeof RANGES)[number];
function parseRange(value: string | undefined): Range {
return (RANGES as readonly string[]).includes(value ?? '') ? (value as Range) : '7d';
}
export default function HabitHistory() {
const params = useLocalSearchParams<{ id: string; range?: string }>();
const range = parseRange(params.range);
const id = params.id;
async function share() {
const url = Linking.createURL(`/habit/${id}/history`, { queryParams: { range } });
await Share.share({ message: `My habit history: ${url}` });
}
return (
<View style={{ flex: 1, padding: 16, gap: 12 }}>
<Stack.Screen options={{ title: `History · ${range}` }} />
<View style={{ flexDirection: 'row', gap: 8 }}>
{RANGES.map((r) => (
<Link key={r} href={{ pathname: '/habit/[id]/history', params: { id, range: r } }} replace asChild>
<Pressable
accessibilityRole="tab"
accessibilityState={{ selected: r === range }}
style={{ paddingHorizontal: 14, paddingVertical: 8, borderRadius: 999, backgroundColor: r === range ? '#4f46e5' : '#e2e8f0' }}
>
<Text style={{ color: r === range ? 'white' : '#0f172a', fontWeight: '600' }}>{r}</Text>
</Pressable>
</Link>
))}
</View>
<Text>Showing {range} of history for habit {id}.</Text>
<Pressable onPress={share} accessibilityRole="button"><Text style={{ color: '#4f46e5' }}>Share this view</Text></Pressable>
</View>
);
}
Two decisions to notice: the range lives in the URL, so a shared link reopens the same view; and
switching range uses replace, so tapping 7d → 30d → 365d doesn't fill the back stack with three
history screens.
How It Actually Works¶
When a URL arrives — from the OS at launch (Linking.getInitialURL()), from the OS while running
(a url event), or from your own router.push — Expo Router runs the same pipeline:
- Strip the scheme and host, leaving a path and query (
/habit/42/history?range=30d). - Match the path against the route tree built from
app/: static segments first, then dynamic[param]segments, then catch-alls, so/habit/newmatcheshabit/new.tsxbeforehabit/[id].tsxif both exist. - Build a navigation state for that match, including all parent navigators — the root stack,
the group, the nested stack — so the screen opens with a sensible back stack. A layout can export
unstable_settings = { initialRouteName: 'index' }to make sure a deep-linked detail screen has its list underneath it. - Dispatch that state (or the difference from the current state) to the root navigator.
Typed routes are generated by scanning the same app/ directory: Expo writes a .d.ts file
(in the .expo/types folder, included by expo-env.d.ts) declaring a union of every valid static
path and every dynamic pathname with its required params. Nothing changes at runtime; it's purely a
compile-time contract.
Common mistakes¶
- Trusting param types —
useLocalSearchParams<{ id: number }>()still gives you a string. - Serialising objects into params — pass IDs.
useGlobalSearchParamseverywhere — needless re-renders of background screens.- Testing deep links only in Expo Go — scheme behaviour differs; use a development build.
- Deep-linked detail screen with no way back — set
initialRouteNameso a parent screen exists. - Forgetting that universal links need the verification file served over HTTPS with the correct content type and no redirects.
Exercise¶
- Add
app/habit/[id]/edit.tsxand a "tags" catch-all routeapp/tag/[...path].tsxthat shows the path segments as breadcrumbs. - Turn on
typedRoutes, restart Metro, and deliberately break a link to see the type error. - Open
habitapp://habit/2/history?range=30don a simulator or emulator in a development build (or, if you only have Expo Go, useLinking.createURLto print the Expo Go URL and open that). Confirm the range tabs reflect the URL and that back goes to a sensible screen.