EduFlow

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.

  1. 1The add-hike form as a modal
  2. 2Protected routes
  3. 3Deep links
  4. 4How a URL picks a route

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:

src/app/_layout.tsx
<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:

src/app/_layout.tsx
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:

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:

Terminal
npx uri-scheme open trailhead://hikes/42 --ios
npx uri-scheme open trailhead://hikes/42 --android

Custom 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 with router.back() after saving.
  • Stack.Protected with a guard hides routes when the condition is false, so signed-out users can’t reach them, even from a deep link.
  • A scheme in app.json makes trailhead://hikes/42 open the /hikes/[id] route; custom schemes need a development build, not Expo Go.
  • When matching a URL, groups and index add no segment, static segments beat dynamic ones, and [...rest] catches whatever is left.

Further reading

Check your understanding

3 questions

Question 1 of 3

Trailhead has both src/app/hikes/new.tsx and src/app/hikes/[id].tsx. Which one renders for /hikes/new?

Question 2 of 3

Signed-out users tap an old email link to trailhead://hikes/42. The hike route sits inside <Stack.Protected guard={isSignedIn}>. What happens?

Question 3 of 3

You add a scheme of “trailhead” to app.json and test trailhead://hikes/42 in Expo Go. Nothing opens. Why?

Practice

Code playground

Match a deep link to a route file

Implement matchRoute(url, routes) the way Expo Router picks a screen for a deep link. routes are file paths without .tsx (see ROUTES). Return { route, params } for the best match, or null.

  • Groups such as (tabs) and a final index add no URL segment.
  • [id] matches exactly one segment and adds it to params as a string.
  • [...path] matches one or more remaining segments and adds them as an array.
  • When several routes match, prefer static segments over [dynamic] ones, and [dynamic] over [...catchAll], comparing from the first segment.
  • Ignore a trailing slash; add query-string values (?units=mi) to params.
const ROUTES = [
  '(tabs)/index',
  '(tabs)/stats',
  'settings/index',
  'settings/units',
  'hikes/new',
  'hikes/[id]',
  'hikes/[id]/photos',
  'trails/[...path]',
];

// Return { route, params } for the best matching route file, or null.
function matchRoute(url, routes) {
  const path = url.replace(/^\//, '');
  const route = routes.find((r) => r === path);
  return route ? { route, params: {} } : null;
}

console.log(JSON.stringify(matchRoute('/hikes/42?units=mi', ROUTES)));
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • Groups and index files add no URL segment
  • Dynamic segments match and become string params
  • A static route beats a dynamic one
  • Catch-all routes collect the remaining segments as an array
  • Query strings become params and trailing slashes are ignored
  • Unknown or too-long paths return null