Nested Navigators and Groups
A stack inside a tab, route groups that never appear in the path, and where a modal route has to be declared.
<Stack.Screen name="compose" options={{ presentation: 'modal' }} />A navigator inside another navigator is nesting, and it is how real apps are shaped: tabs along the bottom, a stack inside each tab. In Expo Router you do not configure that, you nest folders. app/(tabs)/feed/_layout.tsx exporting Stack puts a stack inside the feed tab, so pushing /feed/1042 slides a screen in while the tab bar stays where it is.
Groups
A folder whose name is in parentheses is a route group, and it changes nothing about the path: app/(tabs)/index.tsx is /. Groups exist so a set of routes can share a layout — a tab bar, a stack, a provider — without that appearing in every link. Two sibling groups can therefore hold different shells for the same app, which is the usual way a signed-in and a signed-out experience are split.
Modals
A modal is an ordinary route file whose Stack.Screen in the parent layout carries presentation: 'modal'. presentation: 'formSheet' gives the sheet form, with sheetAllowedDetents as fractions of the screen height and sheetGrabberVisible for the handle.
Which navigator owns the route
Nesting decides what a screen covers. Declared inside (tabs), a route appears with the tab bar still on screen. Declared in the root stack, it covers everything. Headers nest the same way, which is why a parent stack usually sets headerShown: false for a group whose own layout already draws one.
Example
// app/(tabs)/feed/_layout.tsx
// A stack inside a tab. Pushing from the feed keeps the tab bar on screen,
// because this stack is a child of the tabs layout rather than a sibling.
//
// app/
// _layout.tsx root stack
// (tabs)/
// _layout.tsx the tab bar
// feed/
// _layout.tsx this file - the feed tab's own stack
// index.tsx "/feed"
// [id].tsx "/feed/1042"
// profile.tsx "/profile"
import { Stack } from 'expo-router';
export default function FeedStackLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: 'Feed' }} />
<Stack.Screen name="[id]" options={{ title: 'Post' }} />
</Stack>
);
}When to use it
- Giving each tab its own history so switching tabs does not lose the user's place.
- Splitting an app into a signed-in shell and a signed-out shell without changing any paths.
- Opening a compose or filter screen as a sheet over the current screen.
More examples
Modal and sheet routes in the root stack
Reach for a modal when a task interrupts what the user was doing and returns them to it, and a form sheet when the task is short enough that the screen behind should stay visible.
// app/_layout.tsx
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack>
{/* The group draws its own tab bar and headers */}
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
{/* Declared here, outside (tabs), so it slides over the tab bar.
presentation belongs on the Screen in the parent navigator. */}
<Stack.Screen name="compose" options={{ presentation: 'modal', title: 'New post' }} />
<Stack.Screen
name="filters"
options={{
presentation: 'formSheet', // a sheet rather than a full screen
sheetAllowedDetents: [0.4, 1], // snap points as fractions of the height
sheetInitialDetentIndex: 0,
sheetGrabberVisible: true,
}}
/>
</Stack>
);
}A tab whose content is a nested stack
This is the layout almost every tabbed app ends up with: a bar that does not move and a separate history inside each tab. Setting headerShown to false for the nested tab is what stops the two headers appearing.
// app/(tabs)/_layout.tsx
// The name of a Tabs.Screen is the folder or file in this directory. "feed"
// here is the feed/ folder, so the tab renders feed/_layout.tsx and everything
// inside it.
import Ionicons from '@expo/vector-icons/Ionicons';
import { Tabs } from 'expo-router';
export default function TabLayout() {
return (
<Tabs screenOptions={{ tabBarActiveTintColor: '#0a7ea4' }}>
<Tabs.Screen
name="feed"
options={{
title: 'Feed',
// The nested stack draws the header, so this navigator must not.
headerShown: false,
tabBarIcon: ({ color, size }) => <Ionicons name="list" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => <Ionicons name="person" size={size} color={color} />,
}}
/>
</Tabs>
);
}A modal route is just a screen file
The screen itself knows nothing about being a modal, which is why the same file can be presented as a card on one platform and a sheet on another. Keep the dismiss control explicit so Android users are not left looking for a gesture that does not exist.
// app/compose.tsx - the route declared with presentation: 'modal' above
// On screen: a sheet sliding up from the bottom with a multiline input, a live
// character count, and a Post button in its header.
import { useState } from 'react';
import { Stack, useRouter } from 'expo-router';
import { Button, StyleSheet, Text, TextInput, View } from 'react-native';
export default function Compose() {
const [text, setText] = useState('');
const router = useRouter();
return (
<View style={styles.container}>
<Stack.Screen
options={{
title: 'New post',
// Android has no swipe-to-dismiss sheet, so give it a real control.
headerRight: () => <Button title="Post" onPress={() => router.back()} />,
}}
/>
{/* A bare `multiline` is the shorthand for multiline={true}. */}
<TextInput
multiline
value={text}
onChangeText={setText}
placeholder="Say something"
style={styles.input}
/>
<Text style={styles.count}>{text.length} characters</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16, gap: 8 },
input: { minHeight: 120, borderWidth: 1, borderColor: '#d0d7de', borderRadius: 8, padding: 12 },
count: { color: '#687076' },
});
Discussion