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.

Syntax<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

Example · tsx
// 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.

Example · tsx
// 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.

Example · tsx
// 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.

Example · tsx
// 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

  • Be the first to comment on this lesson.