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 elseThree 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.
Navigating
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.