Navigation and app structure
A rider app is a handful of screens, and the way riders move between them decides whether they reach the right job in two taps or get lost at a customer's gate. This lesson shows you how mobile navigation works, how Expo Router turns files into screens, how a link or a push notification opens one exact job, and which state survives when the phone closes your app.
By the end you can
- Choose a stack, tabs or a modal for each screen, and predict what the Android back button and the iOS swipe will do
- Lay out an app in Expo Router with layouts, groups and dynamic routes, passing ids instead of objects
- Set up deep links with a custom scheme and verified https links, and open a job from a notification tap
- Decide where each piece of screen state lives so it survives tab switches, back presses and a killed app
- Gate screens behind sign-in without losing the link the rider tapped
The everyday picture
Think about shopping in a Mangrove store. The aisles work like tabs. You walk from the dairy aisle to the rice aisle and back, and the dairy aisle is the way you left it, with the two yogurts you were comparing still on the shelf. Picking up a jar to read its label works like a stack: you pick up one jar, then maybe a second, and you put them back in reverse order. When the cashier asks "Do you need a bag?", you answer before you can go on. That question works like a modal, a short task that sits on top of everything else.
Now the shelf labels. Every shelf has a code such as "aisle 7, shelf 3". A staff member can send you that code in a message, and you walk straight there without browsing. That is a deep link, an address for one exact spot. The stock room door says "staff only". If you are not staff, you are sent to the front desk, and a good desk writes down where you were trying to go. That is a sign-in gate.
Mobile navigation is these four ideas, plus one rule a real store never faces: the phone can close your app at any moment to free memory, and when the rider comes back, the app must rebuild the store from its notes.
Stacks, tabs and modals
Almost every mobile app is built from three patterns. A stack keeps screens in a pile. Opening a screen pushes it on top, and going back pops it off. In the Rider app, tapping a job pushes the job detail on top of the job list. The list stays mounted underneath, so when the rider goes back it is still scrolled to the same job. The screen that was popped is unmounted, and any state inside it is gone.
Tabs switch between top-level sections that live side by side. The Rider app has two: Jobs and Earnings. In React Navigation 7, which Expo Router is built on, a tab screen mounts the first time the rider opens it and then stays mounted. So the Jobs tab keeps its scroll position while the rider checks today's earnings. A tab can also hold its own stack, with its own history.
A modal is a screen for one short task that covers the current screen and has a clear way out: done or cancel. Proof of delivery fits. The rider takes a photo or enters the customer's code, then returns to the job. On iOS a modal slides up from the bottom, and the rider can drag it down to close it.
Going back works differently on the two platforms, and riders use both. Android has a system back button or back gesture. In a stack it pops the top screen. In bottom tabs, the default backBehavior is firstRoute, so back from Earnings returns to Jobs, and back again leaves the app. iOS has no system back button. The stack draws a back arrow in the header, and the rider can swipe from the left edge of the screen; in the native stack, gestureEnabled is on by default on iOS. Your job is to make sure every kind of back lands somewhere sensible, and that is mostly a matter of choosing the right structure.
To choose, ask what the rider is doing. Going deeper into one thing (the list, then a job, then the store's pickup notes) is a stack. Switching between areas the rider uses all day is tabs; keep them few, and the Rider app needs only two. A self-contained task with an end is a modal. One more rule prevents many bugs: put detail screens in a stack above the tabs, so the tab bar hides on the detail screen and back returns to the tab the rider came from. Expo's own docs recommend this layout.
Quick check
A rider is on the Earnings tab, with default bottom tabs, and presses the Android back button once. What happens?
Bottom tabs default to backBehavior: 'firstRoute', so back from any other tab returns to the first tab. Only a second press, on Jobs, leaves the app. The job detail was popped earlier, so it is no longer in any history.
Expo Router: the file tree is the navigation tree
Expo Router builds the navigation from files. Every file in the app folder becomes a route with a URL, and the folders decide which screens form a stack and which form tabs. Under the hood it uses React Navigation, so stacks, tabs and their options behave as described above; you write files instead of one big configuration object. In Expo SDK 58 the default template keeps routes in src/app; older projects have app at the project root, with the same rules. Here is the Rider app:
src/app/
_layout.tsx root stack: sign-in gate, tabs, detail, modal
sign-in.tsx /sign-in
help.tsx /help open to everyone
(tabs)/
_layout.tsx the tab bar: Jobs and Earnings
index.tsx / Jobs tab: today's job list
earnings.tsx /earnings Earnings tab
jobs/
[id].tsx /jobs/123 job detail, pushed above the tabs
proof/
[jobId].tsx /proof/123 proof of delivery, shown as a modal
+native-intent.tsx checks every link the system hands to the app
+not-found.tsx any URL that matches no file_layout.tsxis not a screen. It decides how the routes in its folder relate, by rendering aStack,Tabsor a plainSlot. The root layout renders before any route, so app-wide setup goes there: the session, fonts, the notification listener.- A folder in parentheses is a route group. It groups files and can have its own layout, but its name never appears in the URL, so
(tabs)/earnings.tsxis/earnings. - Square brackets make a dynamic route.
jobs/[id].tsxmatches/jobs/123and/jobs/124, and the screen reads the value withuseLocalSearchParams(). index.tsxis the default route of its folder.(tabs)/index.tsxis/, the first screen the app opens.- Files starting with
+are special.+not-foundcatches unknown URLs, and+native-intentlets you rewrite a link from the system before the router sees it.
Typed routes. With experiments.typedRoutes set in app.json (projects from the default template already have it), npx expo start generates a type for every route. A typo such as router.push('/jbos') becomes a TypeScript error, and a dynamic route must be given its params as an object: { pathname: '/jobs/[id]', params: { id } }. Expo still calls typed routes beta in SDK 58, so keep the dev server running while you add files, or the types fall behind.
Pass ids, not objects. The tempting move is to hand the whole job to the detail screen. Params end up in the URL, and URL params are strings, so an object has to be turned into text first. Worse, the copy goes stale. The rider's list was loaded at 10:02, the dispatcher changed the drop address at 10:05, and the detail screen still shows the old address from its params. React Navigation's docs give the same advice: params hold identifiers, and the data comes from a store or cache. Pass the id, and let the detail screen read the job from the shared cache the list filled. Then a deep link that carries only an id works too, because the detail screen already knows how to load a job by id. (State, effects and where data should live covers choosing the owner of each piece of data in React.)
Headers. Each stack screen gets a header with a title and a back arrow. Set fixed titles in the layout with options={{ title: 'Help' }}, and dynamic ones from inside the screen by rendering <Stack.Screen options={{ title: job.storeName }} />, so the header shows the pickup store once the job has loaded.
Quick check
The job list passes the whole job object to the detail screen as a param. What goes wrong first?
The copy in the params is a snapshot from when the list loaded, and every way into the screen other than the list (a link, a notification, a cold start) carries only the URL. The real problem is stale and missing data, not a type error. Pass the id and read the job from the shared cache.
Deep links: an address for every screen
A deep link is a URL that opens your app on one exact screen. The Rider app needs them for three ways in: a dispatcher sends a rider a job link in a chat app like WhatsApp or Telegram, a QR code at a store's pickup counter, and a push notification for a new job. Expo Router gives every screen a URL, so the routing part comes almost free. The real work is making links arrive safely.
Custom scheme. Setting "scheme": "mangrove-rider" in app.json tells the phone to hand mangrove-rider://jobs/123 to your app, and the part after the scheme maps to the route /jobs/123. Schemes are quick to set up. A new scheme needs a new development build, because Expo Go only answers its own exp:// links. They have two weaknesses. If the app is not installed, the link does nothing useful, and any app can claim the same scheme, so nothing proves the link belongs to you.
Verified https links. Android App Links and iOS universal links use ordinary https URLs on a domain you own, such as https://rider.mangrove.example/jobs/123. You prove ownership with a small file on that domain: /.well-known/assetlinks.json for Android lists your package name and the SHA-256 fingerprint of your signing certificate, and /.well-known/apple-app-site-association for iOS lists your team ID and bundle ID. In app.json, an Android intent filter with autoVerify: true and the iOS entry applinks:rider.mangrove.example tell each phone to check. When the check passes, the link opens the app directly. When the app is missing, the same URL opens a web page, where you can show the job or a download button. Since Android 12, an https link that fails verification opens in the browser instead, so a wrong fingerprint fails quietly.
Push taps. A notification carries a data payload, and you decide what goes in it. Put a link in it, such as data: { url: 'mangrove-rider://jobs/123' }, and handle two cases. If the app was running, in front or in the background, a listener from Notifications.addNotificationResponseReceivedListener fires when the rider taps. If the app was not running, the tap launches it from scratch (a cold start), and you read that tap with Notifications.getLastNotificationResponse() when the root layout mounts. Expo's docs put both in one hook in the root layout, and so does Build it. Sending notifications and asking for permission are covered in Device features.
Check every link. Treat incoming links as user input. A link can be a typo in a chat message, an old notification for a job that was given to another rider, or someone testing what your app accepts. Parse it, check the host, the path and the id, and send anything wrong to a safe screen, the job list with a short notice, instead of an error page or a crash. One function, parseDeepLink, used by both +native-intent and the notification hook, keeps the rules in one place. You write it in Practice.
Screen state: tab switches, back presses and a killed app
Every piece of state in a screen has a lifetime, and navigation decides how long it is. Before you write a screen, sort its state into these kinds:
| State | Rider app example | Where it lives | What it survives |
|---|---|---|---|
| Which screen, which job | /jobs/123 | The route and its params | Links and notifications; a cold start only if you restore it |
| View state | List scrolled to job 9, a section opened | useState in the screen | Tab switches; lost when the screen is popped or the app is killed |
| Server data | Today's jobs, earnings | A shared cache | Moving between screens; fetched again after a cold start |
| The rider's unsaved work | A delivery note, the job in progress | Storage on the phone, written as the rider goes | Everything, including a killed app |
| Session | The sign-in token | Secure storage | App restarts, until sign-out or expiry |
The killed app. On a phone with 2 to 3 GB of memory, Android closes apps in the background to make room. A rider who follows Google Maps to a customer for fifteen minutes may come back to a fresh start: the JavaScript state is gone, and the Rider app opens on its first screen as if it had never run. iOS does the same under memory pressure. You cannot prevent this, so design for it. Anything the rider would be upset to lose is written to storage as they go, and the app can rebuild "where was I?" from storage. For the Rider app, that is the job in progress. Save its id when the rider picks up the order, and on a cold start open /jobs/<id> if that job is still picked up. The storage choices and the queue for proof of delivery are in Offline first.
Signed-out stack and app stack. A signed-out rider should only see sign-in and help, and a signed-in rider should never land on sign-in. Expo Router expresses this with Stack.Protected in the root layout. Screens inside <Stack.Protected guard={isSignedIn}> exist only while the guard is true. If the rider tries to open one while it is false, Expo Router redirects to the first screen they are allowed to see. When the guard changes, at sign-in or sign-out, screens that are no longer allowed are removed from the history, so back cannot reach them. The guard only decides what the app shows. The API must still check the token on every request, because a guard on the phone protects nothing on the server.
Keep the link at the gate. A rider whose session has expired taps a "new job" notification. The guard sends them to sign-in, and Expo's own sign-in example then goes to /, so the job they wanted is lost unless you keep it. Save the link before the redirect, and open it after sign-in. Also wait until the session is known before you navigate at all. On a cold start the session check and the notification tap arrive together, and a push that runs before the guard knows who is signed in can send the rider to the wrong screen. Keep the splash screen up until the session has loaded.
Quick check
A rider types half a delivery note on the job detail screen, switches to Maps, and Android closes the Rider app. Where should the note have been kept?
A killed app loses all JavaScript state, including useState. Params hold small identifiers and are rebuilt from the link, not from what the rider typed. Only storage on the phone survives the process being closed.
Build it
Turn the job list from How React Native works into a real app structure: a Jobs tab and an Earnings tab, a job detail screen opened by id, a sign-in gate, and deep links from a scheme, an https domain and notification taps, all through one checked door. Continue in your rider-app project (Expo SDK 58). Give the sample jobs numeric ids such as '48213', the format the Rider API uses.
-
Turn on the scheme, typed routes and https links
Add these keys to
app.json(keep the ones the template already has).JSON{ "expo": { "name": "Mangrove Rider", "scheme": "mangrove-rider", "experiments": { "typedRoutes": true }, "ios": { "bundleIdentifier": "example.mangrove.rider", "associatedDomains": ["applinks:rider.mangrove.example"] }, "android": { "package": "example.mangrove.rider", "intentFilters": [ { "action": "VIEW", "autoVerify": true, "data": [{ "scheme": "https", "host": "rider.mangrove.example", "pathPrefix": "/jobs" }], "category": ["BROWSABLE", "DEFAULT"] } ] } } } -
Move the screens into the route tree
Create the files from the tree in the Expo Router section. Move the home screen from rn-01 into
src/app/(tabs)/index.tsxand add one more../to its imports. For now,earnings.tsx,help.tsxandproof/[jobId].tsxcan each return oneText; the proof screen fills up in rn-03 and rn-04. Keep the template's+not-found.tsx. Put the session hook from Expo's authentication guide insrc/features/auth/session.tsx; it exposessession,isLoadingandsignIn. -
Write the root layout: the gate, the stack and the listeners
TypeScript// src/app/_layout.tsx import { Stack } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; import { useEffect } from 'react'; import { SessionProvider, useSession } from '../features/auth/session'; import { useNotificationObserver } from '../features/links/useNotificationObserver'; SplashScreen.preventAutoHideAsync(); // keep the splash until the session is known export const unstable_settings = { anchor: '(tabs)' }; // a link to /jobs/123 gets the tabs below it export default function RootLayout() { return ( <SessionProvider> <RootStack /> </SessionProvider> ); } function RootStack() { const { session, isLoading } = useSession(); useEffect(() => { if (!isLoading) SplashScreen.hide(); }, [isLoading]); useNotificationObserver(!isLoading, !!session); // taps wait for the session (step 6) return ( <Stack> <Stack.Protected guard={!!session}> <Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="jobs/[id]" options={{ title: 'Job' }} /> <Stack.Screen name="proof/[jobId]" options={{ presentation: 'modal', title: 'Proof of delivery' }} /> </Stack.Protected> <Stack.Protected guard={!session}> <Stack.Screen name="sign-in" options={{ headerShown: false }} /> </Stack.Protected> <Stack.Screen name="help" options={{ title: 'Help' }} /> </Stack> ); }The
anchorsetting matters for links: a rider who opens/jobs/123from a chat message still gets the tabs underneath, so back goes to the job list instead of leaving the app. -
Add the tabs, and join the list to the detail by id
TypeScript// src/app/(tabs)/_layout.tsx import Ionicons from '@expo/vector-icons/Ionicons'; import { Tabs } from 'expo-router'; export default function TabLayout() { return ( <Tabs screenOptions={{ tabBarActiveTintColor: '#2f6f4f' }}> <Tabs.Screen name="index" options={{ title: 'Jobs', tabBarIcon: ({ color, size }) => <Ionicons name="list" color={color} size={size} /> }} /> <Tabs.Screen name="earnings" options={{ title: 'Earnings', tabBarIcon: ({ color, size }) => <Ionicons name="wallet" color={color} size={size} /> }} /> </Tabs> ); }TypeScript// src/app/(tabs)/index.tsx: only the onPress changes from rn-01 import { router } from 'expo-router'; // ...the imports and layout from rn-01, with one more ../ in each path <JobCard key={job.id} job={job} now={now} onPress={(id) => router.push({ pathname: '/jobs/[id]', params: { id } })} // the id, never the job />TypeScript// src/app/jobs/[id].tsx: works from the list, a link, a notification or a cold start import { Stack, router, useLocalSearchParams } from 'expo-router'; import { Button, Text, View } from 'react-native'; import { sampleJobs } from '../../features/jobs/sampleJobs'; export default function JobDetail() { const { id } = useLocalSearchParams<{ id: string }>(); // always a string from the URL const job = sampleJobs.find((j) => j.id === id); // the real app reads the shared cache if (!job) return <Text>This job is no longer assigned to you.</Text>; return ( <View style={{ padding: 16, gap: 12 }}> <Stack.Screen options={{ title: job.storeName }} /> <Text>Drop: {job.dropAddress}</Text> <Button title="Deliver" onPress={() => router.push({ pathname: '/proof/[jobId]', params: { jobId: job.id } })} /> </View> ); }In the real app,
sampleJobs.findbecomes a hook over a shared cache ofGET /rider/jobsthat fetches the job when it is missing. A data library such as TanStack Query gives you that cache. -
Send every system link through one door
Put your Practice solutions in
src/features/links/:parseDeepLinkwith a smallhrefFornext to it, andgatewithafterSignIn. Then add a tiny store for a link waiting on sign-in, and the special+native-intentfile.TypeScript// src/app/+native-intent.tsx: runs before routing for links the system opens import { hrefFor, parseDeepLink } from '../features/links/parseDeepLink'; export function redirectSystemPath({ path }: { path: string; initial: boolean }) { try { // handle both a full URL and a bare path such as /jobs/123 const url = path.startsWith('/') ? 'mangrove-rider:/' + path : path; return hrefFor(parseDeepLink(url)); // bad links become '/', the job list } catch { return '/'; // never crash in here } }TypeScript// src/features/links/parseDeepLink.ts: your Practice function goes above this export type DeepLink = | { screen: 'jobs' | 'earnings'; params: Record<string, never> } | { screen: 'job'; params: { id: string } } | { error: string }; export function hrefFor(link: DeepLink): string { if ('error' in link) return '/'; if (link.screen === 'job') return `/jobs/${link.params.id}`; if (link.screen === 'earnings') return '/earnings'; return '/'; }TypeScript// src/features/links/pending.ts: a link that waits for sign-in (memory only) let pending: string | null = null; export function rememberLink(href: string) { pending = href; } export function takeRememberedLink(): string | null { const href = pending; pending = null; // open it once, then forget it return href; } -
Open the right job from a notification tap, after sign-in if needed
TypeScript// src/features/links/useNotificationObserver.ts import { router, type Href } from 'expo-router'; import * as Notifications from 'expo-notifications'; import { useEffect } from 'react'; import { gate } from './gate'; import { hrefFor, parseDeepLink } from './parseDeepLink'; import { rememberLink } from './pending'; export function useNotificationObserver(ready: boolean, signedIn: boolean) { useEffect(() => { if (!ready) return; // wait until we know who is signed in function open(response: Notifications.NotificationResponse) { const url = response.notification.request.content.data?.url; const href = typeof url === 'string' ? hrefFor(parseDeepLink(url)) : '/'; const { show, remember } = gate(signedIn, href); if (remember) rememberLink(remember); // the sign-in screen opens it later else router.push(show as Href); Notifications.clearLastNotificationResponse(); // handle each tap once } const last = Notifications.getLastNotificationResponse(); // the tap that launched the app if (last) open(last); const sub = Notifications.addNotificationResponseReceivedListener(open); // taps while running return () => sub.remove(); }, [ready, signedIn]); }TypeScript// src/app/sign-in.tsx import { router, type Href } from 'expo-router'; import { Button, View } from 'react-native'; import { useSession } from '../features/auth/session'; import { afterSignIn } from '../features/links/gate'; import { takeRememberedLink } from '../features/links/pending'; export default function SignIn() { const { signIn } = useSession(); async function onSignIn() { await signIn(); // your real sign-in form goes here router.replace(afterSignIn(takeRememberedLink()) as Href); // the job they tapped, or the list } return ( <View style={{ flex: 1, justifyContent: 'center', padding: 24 }}> <Button title="Sign in" onPress={onSignIn} /> </View> ); }The observer uses
gateto decide, and the sign-in screen usesafterSignInto finish the job. Install the notifications module withnpx expo install expo-notifications. Sending a real push comes in Device features. -
Make a development build and test every way in
A new scheme and new intent filters are native settings, so Expo Go cannot use them. Build a development build on your machine (this needs Android Studio), or in the cloud with EAS:
Terminalnpx expo install expo-dev-client npx expo run:androidWith the app installed, open a scheme link, a bad link and a verified https link from your computer:
Terminalnpx uri-scheme open mangrove-rider://jobs/48213 --android npx uri-scheme open "mangrove-rider://jobs/48213?from=chat" --android npx uri-scheme open mangrove-rider://jobs/abc --android adb shell am start -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "https://rider.mangrove.example/jobs/48213" example.mangrove.rider adb shell pm get-app-links example.mangrove.riderThe first two open job 48213 with the tabs below it; press back and you land on the job list. The third lands on the job list. The https link opens the app only after
rider.mangrove.exampleserves the two files below, andpm get-app-linksshows whether Android has verified the domain (it can take a little while after install). Use the SHA-256 fingerprint fromeas credentials -p androidor from the Play Console's app signing page.JSON[ { "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "example.mangrove.rider", "sha256_cert_fingerprints": ["PASTE:THE:SHA256:OF:THE:PLAY:APP:SIGNING:KEY"] } } ]JSON{ "applinks": { "details": [ { "appIDs": ["TEAMID1234.example.mangrove.rider"], "components": [{ "/": "/jobs/*", "comment": "job links open the Rider app" }] } ] } }Both go under
/.well-known/on the domain, over https. Last, the killed-app test from the example box: open a job, press Home, force-stop the app in Android settings, and open it again. Until you save the job in progress (rn-03), it opens on the job list, which is exactly the gap to close.
Bring it to your team
Ask these in design reviews and when a new screen is added to the Rider app.
- If I paste a link to a job that is not mine, or to a job id that does not exist, what does the app show?
- Can every screen we link to load its own data from the id in the URL, with an empty cache?
- What does Android back do on each screen, and does the iOS swipe go to the same place?
- If Android closes the app on the proof screen, what does the rider lose? Have we run the force-stop test?
- Where is the link the rider tapped kept while they sign in, and when is it forgotten?
- Who owns
assetlinks.jsonand the Apple association file on our domain, and do they list the Play signing key?
Red flags
- Whole objects passed as params, or screens that only work when opened from the list.
router.push(data.url)straight from a notification, with no checks.- Five or more tabs, or tabs used for the steps of one task.
- Deep links only ever tested in Expo Go, never from a cold start in a development build.
- "The guard hides that screen, so the API does not need to check."
Practice
Parse the Rider app's deep links
Write parseDeepLink(url). It must accept the custom scheme mangrove-rider:// and verified links on https://rider.mangrove.example, and return one of these:
{ screen: 'jobs', params: {} }for/jobsor an empty path (the job list){ screen: 'job', params: { id: '48213' } }for/jobs/48213; ids are digits only, otherwise{ error: 'bad-id' }{ screen: 'earnings', params: {} }for/earnings{ error: 'unknown-host' }for any other scheme or host, including plainhttp{ error: 'unknown-path' }for anything else
A query string and a trailing slash are not part of the path. The starter only handles the scheme form. Tip: turn a scheme link into the https form first, then let new URL() do the parsing.
function parseDeepLink(url) {
// Handles mangrove-rider://jobs/48213 ... and nothing else yet
const parts = url.replace('mangrove-rider://', '').split('/');
if (parts[0] === 'jobs' && parts[1]) return { screen: 'job', params: { id: parts[1] } };
if (parts[0] === 'jobs') return { screen: 'jobs', params: {} };
if (parts[0] === 'earnings') return { screen: 'earnings', params: {} };
return { error: 'unknown-path' };
}
test('scheme links open the job list, a job and earnings', () => {
expect(parseDeepLink('mangrove-rider://jobs/48213')).toEqual({ screen: 'job', params: { id: '48213' } });
expect(parseDeepLink('mangrove-rider://jobs')).toEqual({ screen: 'jobs', params: {} });
expect(parseDeepLink('mangrove-rider://earnings')).toEqual({ screen: 'earnings', params: {} });
});
test('https links on rider.mangrove.example open the same screens as the scheme', () => {
expect(parseDeepLink('https://rider.mangrove.example/jobs/48213')).toEqual({ screen: 'job', params: { id: '48213' } });
expect(parseDeepLink('https://rider.mangrove.example/')).toEqual({ screen: 'jobs', params: {} });
expect(parseDeepLink('https://rider.mangrove.example/earnings')).toEqual({ screen: 'earnings', params: {} });
});
test('a query string or a trailing slash is not part of the id', () => {
expect(parseDeepLink('mangrove-rider://jobs/48213?from=push')).toEqual({ screen: 'job', params: { id: '48213' } });
expect(parseDeepLink('https://rider.mangrove.example/jobs/48213/?from=chat')).toEqual({ screen: 'job', params: { id: '48213' } });
});
test('an id that is not all digits is refused with bad-id, and unknown paths with unknown-path', () => {
expect(parseDeepLink('mangrove-rider://jobs/abc')).toEqual({ error: 'bad-id' });
expect(parseDeepLink('mangrove-rider://jobs/48213/extra')).toEqual({ error: 'unknown-path' });
expect(parseDeepLink('mangrove-rider://settings')).toEqual({ error: 'unknown-path' });
});
test('other hosts, lookalike hosts and plain http are refused with unknown-host', () => {
expect(parseDeepLink('https://evil.example/jobs/48213')).toEqual({ error: 'unknown-host' });
expect(parseDeepLink('https://rider.mangrove.example.evil.example/jobs/1')).toEqual({ error: 'unknown-host' });
expect(parseDeepLink('http://rider.mangrove.example/jobs/1')).toEqual({ error: 'unknown-host' });
});
Show a solution
const SCHEME = 'mangrove-rider://';
const HOST = 'rider.mangrove.example';
function parseDeepLink(url) {
// One parser for both forms: turn the scheme link into the https form first
const text = url.startsWith(SCHEME) ? 'https://' + HOST + '/' + url.slice(SCHEME.length) : url;
let parsed;
try {
parsed = new URL(text);
} catch {
return { error: 'unknown-host' };
}
if (parsed.protocol !== 'https:' || parsed.hostname !== HOST) return { error: 'unknown-host' };
// pathname has no query string; filter drops the empty pieces from slashes
const parts = parsed.pathname.split('/').filter(Boolean);
if (parts.length === 0 || (parts.length === 1 && parts[0] === 'jobs')) return { screen: 'jobs', params: {} };
if (parts.length === 2 && parts[0] === 'jobs') {
return /^\d+$/.test(parts[1]) ? { screen: 'job', params: { id: parts[1] } } : { error: 'bad-id' };
}
if (parts.length === 1 && parts[0] === 'earnings') return { screen: 'earnings', params: {} };
return { error: 'unknown-path' };
}Keep the link at the sign-in gate
Write gate(signedIn, href), which decides what to show when a checked link arrives, and afterSignIn(remember). href is a path such as '/jobs/48213', or null when there is no link. gate returns { show, remember }: the path to open now, and a path to open after sign-in (or null). The rules: signed-in riders get the link, or the job list '/' when there is none, and never the sign-in screen. Signed-out riders can open the public paths in PUBLIC; anything else shows '/sign-in' and remembers the link. afterSignIn returns the remembered link, or '/'. The starter is what Expo's basic example does: it forgets the link.
const PUBLIC = ['/sign-in', '/help'];
function gate(signedIn, href) {
if (!signedIn) return { show: '/sign-in', remember: null };
return { show: href, remember: null };
}
function afterSignIn(remember) {
return '/';
}
test('signed in: open the link, or the job list when there is no link', () => {
expect(gate(true, '/jobs/48213')).toEqual({ show: '/jobs/48213', remember: null });
expect(gate(true, null)).toEqual({ show: '/', remember: null });
});
test('signed in: a link to /sign-in goes to the job list instead', () => {
expect(gate(true, '/sign-in')).toEqual({ show: '/', remember: null });
});
test('signed out: show sign-in and remember the job link, so it is not lost', () => {
expect(gate(false, '/jobs/48213')).toEqual({ show: '/sign-in', remember: '/jobs/48213' });
expect(gate(false, null)).toEqual({ show: '/sign-in', remember: null });
});
test('signed out: public paths such as /help open without sign-in', () => {
expect(gate(false, '/help')).toEqual({ show: '/help', remember: null });
expect(gate(false, '/sign-in')).toEqual({ show: '/sign-in', remember: null });
});
test('after sign-in: open the remembered link, or the job list', () => {
expect(afterSignIn('/jobs/48213')).toBe('/jobs/48213');
expect(afterSignIn(null)).toBe('/');
});
Show a solution
const PUBLIC = ['/sign-in', '/help'];
function gate(signedIn, href) {
if (signedIn) {
// a signed-in rider never lands on sign-in
if (!href || href === '/sign-in') return { show: '/', remember: null };
return { show: href, remember: null };
}
if (href && PUBLIC.includes(href)) return { show: href, remember: null };
return { show: '/sign-in', remember: href || null }; // keep the link for later
}
function afterSignIn(remember) {
return remember || '/';
}Check yourself
Imran wants two new things: a "Cash summary" that riders open from the Earnings tab at the end of a shift, and a "Report a problem" form opened from a job, which the rider fills in and sends. What structure fits best?
Cash summary goes deeper into earnings, which is a stack. Reporting a problem is a short, self-contained task with a clear end, which is a modal. New tabs are for areas used all day, and a third and fourth tab for occasional screens crowd the bar. Putting cash screens in the Jobs history makes back behave strangely.
The backend team asks what to put in the "new job" push notification. Which payload is the best choice?
A link with an id goes through the same checks as any other link, and the screen loads fresh data, so a job that was changed or reassigned in the meantime shows correctly. A full job copy can be stale by the time it is tapped, and component names tie the server to the app's code. No link at all makes the rider search, which is what the notification was meant to save.
A rider scrolls the Jobs tab to job 14, opens Earnings, and comes back. Then they open a job, type a note held in useState, press back, and open the same job again. What is still there?
Tab screens stay mounted when you switch tabs, so the list keeps its scroll position. Pressing back pops the job detail, which unmounts it and discards its useState; opening the job again mounts a fresh screen. Work the rider must not lose belongs in storage.
Flashcards
Key takeaways
- Stacks go deeper, tabs switch between areas used all day, and modals hold short tasks. Put detail screens above the tabs so back behaves.
- In Expo Router the file tree is the navigation:
_layoutfiles pick the navigator,(groups)stay out of the URL, and[id]makes a dynamic route. - Pass ids, not objects, so every screen can rebuild itself from its URL, whether the rider came from the list, a link or a notification.
- Send scheme links, verified https links and notification taps through one checked door, and send bad links to a safe screen.
- Plan for a killed app and an expired session: save the rider's work and the job in progress, and keep the tapped link while they sign in.
Sources
- Core concepts of file-based routing, Expo documentation
- Protected routes, Expo documentation
- Authentication in Expo Router, Expo documentation
- Android App Links and iOS Universal Links, Expo documentation
- Notifications, Expo SDK reference
- Navigation lifecycle and Passing parameters to routes, React Navigation 7 documentation
- Handling Android App Links, Android Developers