React Native – Navigation

July 10, 20264 min readUpdated 8/24/2026

Navigation on a phone is not a router over URLs. It is a stack of native screens — pushed, popped, swiped back, each with its own header and its own place in a history the OS knows about. Getting that model right matters more than the library you pick.

Expo Router: the folder IS the graph

Expo Router is file-based routing, built on React Navigation. The structure under app/ is the navigation graph, so there is no route table to keep in sync with the files:

app/
  _layout.tsx           the root navigator
  (tabs)/
    _layout.tsx         the tab bar
    index.tsx           /
    menu.tsx            /menu
  checkout.tsx          /checkout
  order/[orderId].tsx   /order/:orderId
  +not-found.tsx        everything else

Three conventions carry all of it. _layout.tsx declares a navigator and wraps everything beside and below it. Parentheses make a group(tabs) organises files without adding a /tabs segment to any URL. Square brackets declare a dynamic segment.

The root stack

    <Stack
      screenOptions={{
        headerStyle: { backgroundColor: theme.colors.surfaceInverse },
        headerTintColor: theme.colors.onSurfaceInverse,
        headerTitleStyle: { fontWeight: theme.fontWeight.bold },
        contentStyle: { backgroundColor: theme.colors.background },
      }}
    >
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      <Stack.Screen name="checkout" options={{ title: 'Checkout' }} />
      <Stack.Screen name="order/[orderId]" options={{ title: 'Your order' }} />

The files define the routes; Stack.Screen only configures them. You do not have to list a screen at all — it works without an entry — but naming it is where the title and the transition go.

headerShown: false on (tabs) is not cosmetic. The tab navigator draws its own headers, so without this you get two stacked headers, which looks exactly as bad as it sounds.

Presentation

      <Stack.Screen name="login" options={{ title: 'Sign in', presentation: 'modal' }} />
      <Stack.Screen name="register" options={{ title: 'Create account', presentation: 'modal' }} />
    </Stack>

presentation: 'modal' gives the iOS card-over-the-app transition — the platform's way of saying "this is a detour, swipe down to leave". Using it for sign-in says something true about the app: ordering never requires an account, so authentication is a side trip rather than a gate.

Tabs

        <Tabs.Screen
          name="index"
          options={{
            title: 'StayHub Pizza',
            tabBarLabel: 'Home',
            tabBarIcon: ({ color }) => <TabIcon glyph="🏠" color={color} />,
          }}
        />

A tab bar rather than a hamburger, because that is what a phone expects: the destinations are always visible and always within thumb reach. title is the header; tabBarLabel is the tab — worth separating when the header wants the brand name and the tab wants one short word.

Each tab keeps its own navigation state, so pushing a screen inside one tab and switching away leaves it where it was.

Route files should be one line

export default function HomeRoute() {
  return <HomeScreen />;
}

The route's job is to say which screen lives at this path. The screen's job is to render it. Keeping them apart means the screen can be rendered in a test without a router at all, and moving a screen to a different path is a file rename rather than a refactor.

Every route in the demo app looks like this. It is a small discipline that pays back immediately.

Dynamic segments

export default function OrderRoute() {
  const { orderId } = useLocalSearchParams<{ orderId?: string }>();

  if (!orderId) {
    return (
      <Screen>
        <EmptyState
          emoji="🧾"
          title="Order not found"
          message="That link is missing an order id."
        />
      </Screen>
    );
  }

  return <OrderConfirmationScreen orderId={orderId} />;
}

useLocalSearchParams reads the segment. Note the type: orderId is optional, not guaranteed — and that is honest rather than pedantic. A malformed deep link or a push notification can genuinely arrive without one, so the guard turns a crash into a message.

There is also useGlobalSearchParams, which reads params from anywhere in the tree. Prefer the local one: it only updates when this screen is focused, so a screen sitting underneath in the stack does not re-render every time something above it changes.

      router.replace(filter === 'ALL' ? '/menu' : `/menu?type=${filter}`);

router.push adds to the stack. router.replace swaps the current entry. router.back() pops.

The distinction matters more than on the web because the back gesture is constant. Changing a filter is not a place to go back to, so it replaces. Conversely, after a successful payment the confirmation screen is reached with replace so the back gesture cannot return to a checkout for an order that is already paid.

There is also <Link href="/menu"> for declarative navigation, which is usually nicer in a list of destinations.

Typed routes

experiments: { typedRoutes: true } in the Expo config generates types from your route files, so router.push('/menuu') stops compiling. It costs one line and removes an entire category of runtime-only bug.

What is next

Deep Linking and the URL as State — the same routes, reached from outside the app.