skip to content

Protected & Modal Routes

Stack.Protected hides screens behind a guard and falls back to the anchor route, and presentation options turn a route into a modal. Interviewers ask how to build sign-in without a navigation flicker.

part ofExpo (React Native)overview, primer and where to startread it →
on this pageshow

explore

questions

4

With Expo Router, how do you present a streaming app's plan-picker route as a modal or a draggable sheet instead of a pushed screen?

level: juniorimportance: should knowfreq 42%

answer

  1. set on the layout, not the file
  2. Stack.Screen options.presentation
  3. iOS swipe down, Android back
  4. formSheet plus sheetAllowedDetents
  5. Android: at most three detents

basics

~10 s

Declare the route in its stack layout with a Stack.Screen whose options set presentation: 'modal', or presentation: 'formSheet' plus sheetAllowedDetents such as [0.5, 1] for a sheet that rests at half and full height.

solid answer

~40 s

The route file stays an ordinary screen; the **stack layout** decides how it appears. In `_layout.tsx` I add `<Stack.Screen name="plans" options={{ presentation: 'modal' }} />`, so pushing `/plans` slides it up on iOS, where a swipe down dismisses it, and shows it on top on Android, where the back button dismisses it. For a sheet I use `presentation: 'formSheet'` with `sheetAllowedDetents` (default `[1.0]`), for example `[0.5, 1]`, and `sheetInitialDetentIndex` (default `0`). Android honours at most three detents, `sheetGrabberVisible` is iOS-only, and `'fitToContents'` needs explicitly sized content instead of `flex: 1`. Unlike the core `Modal` component, a modal route has a URL and lives in navigation history.

code

tsx · 20 lines
tsx
// src/app/_layout.tsx
import { Stack } from 'expo-router';

export default function Layout() {
  return (
    <Stack>
      <Stack.Screen name="index" />
      <Stack.Screen name="plans" options={{ presentation: 'modal' }} />
      <Stack.Screen
        name="episode/[id]"
        options={{
          presentation: 'formSheet',
          sheetAllowedDetents: [0.5, 1],
          sheetInitialDetentIndex: 0,
          sheetGrabberVisible: true,
        }}
      />
    </Stack>
  );
}

go deeper

for a junior

Recall where the setting lives: a Stack.Screen in the layout with options.presentation set to 'modal' or 'formSheet'.

for a middle

Explain the platform dismissal difference, the detent options and their defaults, and when a modal route beats the core Modal component.

for a senior

Anticipate the traps: Android's three-detent limit, fitToContents needing sized content, iOS-only options, and a deep-linked modal that needs an anchor behind it.

for a principal

Decide which flows deserve a URL: modal routes are linkable and restorable, so treat them as navigation surface area with the testing and analytics that implies.

## Two ways to show something modally An Expo app has two different tools that both look like "a modal": | Tool | What it is | Use it for | |---|---|---| | React Native `Modal` component | A view rendered over the current screen, outside navigation | Confirmations, short dialogs | | A **modal route** in Expo Router | A file route presented modally by a stack | Flows with their own URL, like a plan picker | A modal route is a real screen: it has a path such as `/plans`, it can be opened with a `Link` or the `router` object, and a link can open it directly. That is what a streaming app's "Choose your plan" flow needs. ## Making a route modal The file itself, `src/app/plans.tsx`, is an ordinary screen component. What makes it modal is the **layout that declares it**. In the stack's `_layout.tsx`, you add a `Stack.Screen` for the route and set its `presentation` option: ```tsx <Stack> <Stack.Screen name="index" /> <Stack.Screen name="plans" options={{ presentation: 'modal' }} /> </Stack> ``` Pushing `/plans` now presents it modally instead of sliding it in as a card. The platforms differ: - **iOS**: the screen slides up from the bottom and is dismissed by swiping it down. - **Android**: the screen appears on top of the current one and is dismissed with the system back button. - **Web**: by default the route renders as its own page, so the screen should offer its own close control; Expo also has an alpha web-modal mode behind an environment variable. ## `presentation` values The `presentation` option comes from the native stack that Expo Router's `Stack` wraps: - `card` — the default push onto the stack. - `modal` — presented modally; it can host a nested stack of its own. - `transparentModal` — modal, with the previous screen still visible behind a translucent background. - `containedModal` and `containedTransparentModal` — iOS "current context" styles; Android falls back to `modal` and `transparentModal`. - `fullScreenModal` — iOS full-screen modal style; Android falls back to `modal`. - `formSheet` — a **bottom sheet** that rests at configurable heights. ## `formSheet` and detents A **detent** is a height where a sheet can rest. For a streaming app's "episode details" sheet that opens half-height and can be dragged to full screen, set `presentation: 'formSheet'` and configure: 1. `sheetAllowedDetents` — an ascending array of fractions of the screen height, such as `[0.5, 1]`, or the literal `'fitToContents'`. The default is `[1.0]`, a single full-height detent. 2. `sheetInitialDetentIndex` — the index the sheet opens at; the default is `0`, the smallest detent, and `'last'` is allowed. 3. `sheetGrabberVisible` — shows the grabber handle; **iOS only**. 4. `sheetCornerRadius` and `sheetLargestUndimmedDetentIndex` — the corner radius, and the largest detent at which the screen behind stays undimmed. Platform limits to remember: - **Android honours at most three detents**; extra values are ignored. iOS accepts any number. - **The array must be ascending**; development builds raise an error otherwise. - **`'fitToContents'` needs explicitly sized content.** The sheet measures its content to choose its height, so a root `View` with `flex: 1` has nothing to measure. With numeric detents, `flex: 1` fills the sheet (on iOS, since SDK 55). ## Choosing between them - A transient question ("Remove this title from Downloads?") belongs in the core `Modal` or an alert: it has no URL and no reason to be in history. - A flow a user might be sent to from outside the app, like the plan picker or the episode details sheet, belongs in a modal route, so it has a path and survives a reload on web. - A partial-height sheet with drag handles is `formSheet`; a full-cover takeover is `modal` or `fullScreenModal`. When a modal route can be opened directly by a link, give its stack an **anchor** so a screen sits behind it; that is a separate setting on the layout. Closing a modal from code uses the `router` object's back and dismiss calls. These option names and defaults are those of Expo SDK 57 (Expo Router 57.x on React Native 0.86), whose `Stack` wraps React Navigation 7's native stack.

  • When would you use React Native's core Modal instead of an Expo Router modal route?
    For a transient interaction that has no reason to be in navigation: a confirmation such as removing a title from downloads, or a short dialog. The core `Modal` renders over the current screen without a URL or history entry. A flow a user might be linked to, or that should survive a web reload, such as a plan picker, belongs in a modal route.
  • A formSheet uses sheetAllowedDetents: 'fitToContents' and appears with zero height. Why?
    With `'fitToContents'` the sheet sizes itself by measuring its content. If the screen's root view uses `flex: 1`, it has no intrinsic height to measure, so the sheet has nothing to fit. Give the content an explicit size, or switch to numeric detents such as `[0.5, 1]`, where `flex: 1` fills the sheet.

A modal route is a shop with its own address in the mall directory, so someone can send you straight to it; the core Modal is a notice held up in the corridor for a moment, with no address at all.

saying these in an interview costs you the question

  • Presentation is set by exporting options from the modal file
  • A modal route has no URL, just like the core Modal
  • formSheet accepts any number of detents on Android
  • sheetGrabberVisible shows a grabber on both platforms
  • fitToContents works with a flex: 1 root view
  • The default sheetAllowedDetents is [0.5, 1]
open as a page

With Expo Router, how does Stack.Protected's guard prop keep a streaming app's account screens from signed-out users, and where does a blocked user land?

level: middleimportance: should knowfreq 38%

basics

~20 s

Screens wrapped in <Stack.Protected guard={isSignedIn}> are left out of the navigator while the guard is false. A deep link to one lands on the anchor route or first available screen, and a guard flipping false removes their history entries.

open as a page

In an Expo Router app, how do you restore a saved session at launch so the sign-in screen never flashes before the account area appears?

level: seniorimportance: should knowfreq 32%

basics

~20 s

Keep the native splash screen up with SplashScreen.preventAutoHideAsync() until an auth provider in the root layout has read the stored session, then hide it. Stack.Protected guards read that provider, so the first visible frame already has the right screens.

open as a page

In Expo Router, why does a deep link straight to a modal route leave no screen behind it, and what does unstable_settings.anchor change?

level: middleimportance: nice to knowfreq 18%

basics

~20 s

A deep link builds the stack from the URL alone, so it holds only the modal. Exporting unstable_settings = { anchor: 'index' } from that stack's layout keeps index rendered underneath, and the same anchor is where guarded deep links land.

open as a page