Stack Navigation
Pushing and popping screens, the header you get for free, and why replace leaves nothing to go back to.
router.push('/order/1042') · router.replace('/home') · router.back()A stack is a pile of screens. The newest covers the ones below it, and going back removes the top one. It is the shape behind almost every tap-a-row-see-the-detail interaction, and a layout file exporting Stack is all it takes.
Push, replace, back
router.push('/order/1042') adds a screen. router.back() removes it. router.replace('/home') swaps the current screen for another, so the screen you were on is gone from the stack while anything beneath it stays. router.navigate() pushes, or unwinds to that route if it is already in the stack. For longer flows there is router.dismiss(count), router.dismissAll() to return to the first screen, and router.dismissTo(href), which pops back to a route or replaces the current one if that route is not in the stack.
The header
A stack draws a header for you. title sets the text, and the back arrow appears whenever a screen sits underneath. Set options for every screen with screenOptions on Stack, for one screen with options on Stack.Screen, or at runtime by rendering Stack.Screen inside the screen itself once the data is known.
It looks like CSS and it is not
headerStyle is a style object applied to one view, with no cascade and no inheritance: headerTintColor colours the back arrow and the title, headerTitleStyle handles the title text, and nothing set on the header reaches the screen below it. Hiding the header with headerShown: false removes its status-bar inset too, so the screen then has to handle that itself.
Example
// app/_layout.tsx - one stack for the whole app
// On screen: every route gets a teal header with white text; the orders list
// shows "Orders", a pushed order screen shows "Order" with a back arrow, and
// the scanner screen has no header at all.
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack
// screenOptions applies to every screen in this stack. There is no
// cascade beyond this: it does not reach a nested navigator's screens.
screenOptions={{
headerStyle: { backgroundColor: '#0a7ea4' },
headerTintColor: '#fff', // back arrow and title colour
headerTitleStyle: { fontWeight: '600' },
}}
>
<Stack.Screen name="index" options={{ title: 'Orders' }} />
{/* The back arrow appears on its own: there is a screen underneath. */}
<Stack.Screen name="order/[id]" options={{ title: 'Order' }} />
{/* A full-bleed screen draws its own chrome, so switch the header off. */}
<Stack.Screen name="scanner" options={{ headerShown: false }} />
</Stack>
);
}When to use it
- Opening a record's detail screen from a list and letting the back gesture return to it.
- Replacing a loading or welcome screen so the user cannot go back to it.
- Putting a Save button in the header of an editing screen.
More examples
push against replace, in markup and in code
Four tappable rows doing two things. Use push for anything the user should be able to back out of, and replace for a screen that must not be returned to, such as a splash or a completed sign-in.
// app/index.tsx
import { Link, useRouter } from 'expo-router';
import { Pressable, StyleSheet, Text, View } from 'react-native';
export default function Index() {
const router = useRouter();
return (
<View style={styles.container}>
{/* push adds a screen on top; back returns here */}
<Pressable style={styles.row} onPress={() => router.push('/order/1042')}>
<Text>Open order 1042</Text>
</Pressable>
{/* replace swaps this screen for the next one: nothing to go back to */}
<Pressable style={styles.row} onPress={() => router.replace('/welcome')}>
<Text>Start over</Text>
</Pressable>
{/* The same two operations as Link props. A bare `replace` is the
shorthand for replace={true} - the same prop either way. */}
<Link href="/order/1042" style={styles.row}>
Open order 1042 (push)
</Link>
<Link href="/welcome" replace style={styles.row}>
Start over (replace)
</Link>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16, gap: 8 },
row: { paddingVertical: 12 },
});A title that depends on the data
The header reads "Order 1042" instead of a generic label. This is the right tool whenever the title, a header button or headerShown depends on something only the screen knows.
// app/order/[id].tsx
import { Stack, useLocalSearchParams } from 'expo-router';
import { StyleSheet, Text, View } from 'react-native';
export default function OrderScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
return (
<View style={styles.container}>
{/* Rendering Stack.Screen inside the screen sets that screen's options
at runtime. The layout file cannot know this title in advance. */}
<Stack.Screen options={{ title: `Order ${id}` }} />
<Text style={styles.body}>Order {id} is on its way.</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24 },
body: { fontSize: 16 },
});Ending a flow with dismissAll and dismissTo
Shows how to leave a multi-step flow without the user walking back through every step. dismissAll returns to the first screen of the stack; dismissTo aims at one specific route.
// app/checkout/done.tsx - the last screen of a three-screen flow
import { Stack, useRouter } from 'expo-router';
import { Button, StyleSheet, Text, View } from 'react-native';
export default function Done() {
const router = useRouter();
return (
<View style={styles.container}>
<Stack.Screen
options={{
title: 'Done',
// Going back into a finished flow is wrong, so remove the arrow.
headerBackVisible: false,
headerRight: () => <Button title="Close" onPress={() => router.dismissAll()} />,
}}
/>
<Text style={styles.body}>Order placed.</Text>
{/* dismissTo pops back to that route, or replaces the current screen
if the route is not in the stack at all. */}
<Button title="Back to orders" onPress={() => router.dismissTo('/')} />
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24, gap: 16 },
body: { fontSize: 16 },
});
Discussion