Expo Router: Nested Layouts and Modals
AI generated
RN
native
React Native / Navigation
Expo Router: Nested Layouts and Modals
How shared UI elements survive across multiple route levels and modals fit cleanly into file based routing

Once an Expo Router app grows past a single tab bar, the question of how headers, tab bars and other shared UI elements stay consistent across multiple route levels becomes unavoidable, without every single screen repeating the same configuration. Nested _layout files solve exactly this problem by nesting like React components, with every folder level able to add its own navigator on top. This article covers how that nesting works in practice, how modal presentation gets controlled through the file system, and how a tab navigation with a modal detail screen on top gets built concretely.

11 min read Expo Router Layouts Modals

1. Nested _layout files: the core principle of Expo Router

Expo Router maps an app's navigation structure directly onto the folder structure inside the app directory, where any file named _layout.tsx acts as a wrapper for every route inside the same folder and all its subfolders. A layout is, technically, nothing more than a React component that returns a navigator, be it a Stack, a Tabs element or a Drawer, and controls how the routes underneath it get rendered through Slot or Screen components. This structure lets navigation be described declaratively through the file system hierarchy, instead of being maintained centrally in a single configuration file.

The key difference from a flat navigation configuration lies in the nesting itself: every deeper folder level can introduce its own additional navigator, living inside the parent navigator. A root layout at app/_layout.tsx wraps the entire app and is a good fit for global providers and the outermost stack level, while a layout further down, at app/(tabs)/_layout.tsx, only affects the area inside the tab navigation. This nesting follows exactly the same logic as nesting React components, except Expo Router derives the mapping automatically from the file system.

2. Sharing common UI elements across multiple route levels

The real value of nested layouts shows up once several screens need the same header, the same tab bar or the same frame, without that configuration being rewritten in every single file. An app/(tabs)/_layout.tsx defines the tab bar once, centrally, and every screen inside that folder automatically inherits the same tab bar at the bottom of the screen without containing any navigator logic itself. If the tab bar's design changes later, a single change in the layout file is enough instead of updating ten or twenty screens individually.

The same principle applies to more deeply nested areas: an app/(tabs)/profile/_layout.tsx can introduce its own stack with its own header style inside the profile tab, one that differs from the header used by the other tabs, for example with a different background color or extra header buttons. That inner stack stays fully embedded inside the outer tab navigation, so the tab bar at the bottom stays visible while several profile sub pages, such as settings or security, get pushed and popped through forward and back navigation up top.

Expo Router does not treat modals as a separate concept but as a specific presentation style inside a stack navigator, set through a screen's presentation option. When a screen is configured with presentation: 'modal', the underlying native navigation renders it as an overlay that slides in from the bottom and can typically be dismissed with a downward swipe gesture, instead of the usual horizontal stack transition. This can be set either directly on an individual screen definition inside a stack, or, in a cleaner structure, through a dedicated route group such as (modals), whose layout file sets presentation: 'modal' consistently for every route inside it.

On iOS 16 and later, the same configuration also supports formSheet as a presentation style, where the modal only takes up part of the screen while the background stays visible, which suits short, focused interactions such as picking a quantity or opening a filter dialog. On Android, the fully covering modal display remains the only option, since formSheet has no native equivalent there, which is why a team building for both platforms should deliberately decide whether that difference is acceptable for the use case at hand.


// app/(modals)/_layout.tsx
import { Stack } from 'expo-router';

export default function ModalsLayout() {
  return (
    <Stack
      screenOptions={{
        presentation: 'modal',
        headerShown: true,
        gestureEnabled: true,
      }}
    >
      <Stack.Screen name="filter" options={{ title: 'Filter' }} />
      <Stack.Screen name="[productId]" options={{ title: 'Product Details' }} />
    </Stack>
  );
}

4. Practical example: tab navigation with a modal detail screen

A realistic scenario combines a tab navigation as the main structure with a detail screen that appears as a modal on top of the tabs, for example when a single product opens in a focused view from a product list. The folder structure for this deliberately separates two route groups: app/(tabs) holds the actual tab navigation with screens like index, favorites and account, while app/product/[id].tsx lives outside the tabs at the root level and gets presented as a modal in the root stack. This keeps the tab bar hidden while the product is open, because the modal takes up the full screen, exactly as users would expect from native iOS and Android apps.

The root layout at app/_layout.tsx defines the outermost stack, which holds both the tabs group and the individual modal route as sibling screens. Navigating to /product/42 through router.push therefore opens the product screen not inside the tabs but as its own screen in the root stack, which is why the tab bar correctly disappears while the modal is active and reappears exactly where the navigation started once the modal closes.


// app/_layout.tsx
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      <Stack.Screen
        name="product/[id]"
        options={{ presentation: 'modal', title: 'Product Details' }}
      />
    </Stack>
  );
}

// call from any tab screen
import { router } from 'expo-router';

function openProduct(id: string) {
  router.push(`/product/${id}`);
}

5. Route groups: file system organization without touching the URL

Folder names in parentheses such as (tabs) or (modals) are treated by Expo Router as route groups, which exist purely to organize the file system and deliberately do not add their own URL segment. A route at app/(tabs)/index.tsx is reachable at /, not at /(tabs)/, because the parentheses are stripped when the final route gets built. This lets routes be grouped by theme or by which layout they belong to, without accidentally changing the publicly visible URL structure, which matters especially for deep linking configurations and existing web links.

In practice this leads to several parallel route groups on the same directory level, such as (tabs) for the main navigation, (modals) for every overlay screen and (auth) for the signed out state of the app. Each of these groups can have its own _layout.tsx with its own navigator, cleanly separating different navigation models within the same app without needing a single central configuration file to cover every case at once.

6. Passing layout specific header configuration down

Every _layout file can set default values for every screen it contains through screenOptions, such as a specific header background color or a consistent tint color for back buttons, while an individual screen can override those defaults through its own options. This inheritance cascades across as many nesting levels as needed, letting global defaults be set in the root layout and adjusted selectively in deeper layouts, without duplicating the global configuration every time.

For dynamic titles, such as showing a product's name in the header of a detail page, the screen in question reads the matching route parameter through useLocalSearchParams and passes it to Stack.Screen using an options function that returns an individual title based on the loaded data. This mechanism works regardless of which layout level the screen sits at, turning dynamic header titles into a local concern of the individual screen rather than something requiring global state.

7. Sharing state across nested layout levels

Because every layout is a regular React component, a context provider can be placed directly inside a _layout file and automatically wraps every route underneath it, no matter how deeply nested those routes are. A CartProvider in the layout at app/(tabs)/_layout.tsx makes cart state available to every tab screen, for example, while an AuthProvider placed in the root layout stays visible to modal routes and authentication screens outside the tabs as well.

What matters here is placing the provider deliberately at the right level, since a provider placed too high up unnecessarily re-renders too many components on every change, while one placed too low is simply unreachable from sibling routes outside that layout. As a rule of thumb, place the provider at the level that actually matches the data's real visibility scope, neither in the global root layout for purely local tab state nor inside a single screen for data that several screens need to share.


// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { CartProvider } from '../../context/CartContext';

export default function TabsLayout() {
  return (
    <CartProvider>
      <Tabs screenOptions={{ headerShown: true }}>
        <Tabs.Screen name="index" options={{ title: 'Home' }} />
        <Tabs.Screen name="favorites" options={{ title: 'Favorites' }} />
        <Tabs.Screen name="account" options={{ title: 'Account' }} />
      </Tabs>
    </CartProvider>
  );
}

8. Deep linking to nested modal routes

Because Expo Router derives its linking configuration automatically from the folder structure, a deep link to a modal route such as myapp://product/42 works without any extra manual linking table, unlike what would be needed with classic React Navigation. Opening such a link makes Expo Router automatically reconstruct the full navigation state, so both the underlying tab screen in the background and the modal product screen in front of it get built correctly, even though the user just opened the app through this single link.

That automatic reconstruction is what keeps back navigation consistent: if the user dismisses the modal after opening it through a deep link, they do not land on an empty screen but exactly on the tab screen that counts as the logical starting point of the navigation. For cases where no meaningful starting point can be reconstructed, such as a very deeply nested link with no prior navigation history, a fallback through initialRouteName or an explicit start screen should be defined, so the app never ends up in a navigationally unreachable state.

9. Common pitfalls with nested layouts

The most common mistake happens when both a parent and a child layout independently set headerShown: true, causing two headers to render on top of each other because both stack levels display their own header independently. The fix is to consistently enable the header on exactly one level and explicitly disable it with headerShown: false everywhere else, instead of relying on an implicit default that can change between Expo Router versions.

A second, subtler mistake comes from an accidentally placed _layout.tsx in a folder that was only meant for grouping, unintentionally introducing an extra navigator level and suddenly changing the transition behavior between screens. On top of that, very deep nesting, five or more stack levels stacked inside one another, produces noticeable render overhead in practice, which is why a deliberately flat layout structure with clearly separated route groups tends to hold up better than a deeply nested folder hierarchy.

Layout file Navigator type Scope Typical use
app/_layout.tsx Stack Entire app Root navigation, global providers
app/(tabs)/_layout.tsx Tabs All tab screens Bottom tab bar with a shared header
app/(modals)/_layout.tsx Stack, presentation: modal Modal routes Detail screens as an overlay
app/(tabs)/profile/_layout.tsx Stack Profile sub pages Nested navigation inside one tab
app/product/[id].tsx Screen (root stack) Single product screen Modal outside the tab navigation

Mironsoft

React Native app development and Magento integration

A mobile app for the Magento shop that actually runs smoothly?

We build React Native apps cleanly connected to the Magento REST or GraphQL API, from the first line of code to publishing on the App Store and Google Play.

App Concept

Plan the architecture and feature scope of a Magento-connected app together.

Magento API Integration

Cleanly connect product catalog, cart, and checkout to the shop API.

Store Publishing

Guide the App Store and Google Play release process without pitfalls.

10. Summary

Expo Router Layouts and Modals: Key Takeaways

Principle

Every _layout.tsx wraps every route in the same folder and below, nested folders add further navigator levels.

Modals

presentation: modal controls the overlay display, either directly on a screen or centrally through a (modals) group.

Route groups

Parentheses like (tabs) organize files but do not add their own URL segment.

Pitfalls

Double headers and deep nesting are the most common causes of unexpected navigation behavior.

11. FAQ: Expo Router Layouts and Modals: Key Takeaways

1What exactly is a _layout.tsx file in Expo Router?
It is a React component that wraps every route inside the same folder and all its subfolders while defining a navigator such as Stack, Tabs or Drawer. It functions as a layout wrapper and follows the same nesting logic as React components.
2How does a screen get displayed as a modal?
Through the presentation: 'modal' option in a stack navigator's screen options, either directly on the individual screen or consistently through the screenOptions of a dedicated route group such as (modals). The native navigation then renders the screen as an overlay sliding in from the bottom instead of a horizontal transition.
3What is the difference between presentation modal and formSheet?
Modal takes up the entire screen, while formSheet, available on iOS 16 and later, only covers part of the screen and leaves the background visible. Android has no native equivalent to formSheet, so the fully covering modal display remains the only option there.
4Why doesn't the tab bar disappear automatically when a modal opens?
The tab bar only disappears correctly when the modal screen sits as a sibling route to the tabs group in the root stack, not inside the tabs group itself. If the modal screen is mistakenly placed inside app/(tabs), the tab bar stays visible because it belongs to the same tabs navigator.
5Do folders with parentheses like (tabs) add their own URL segment?
No, route groups in parentheses exist purely to organize the file system and get stripped when the final URL is built. A route at app/(tabs)/index.tsx is reachable at /, not at /(tabs)/.
6How do header settings get inherited between nested layouts?
screenOptions in a parent _layout.tsx act as defaults for every screen and layout underneath it, but can be overridden on any deeper level through an individual screen's options. This inheritance cascades across as many levels as needed.
7Where should a context provider for shared state be placed?
Exactly at the layout level that matches the data's real visibility scope. A provider placed too high causes unnecessary re-renders, while one placed too low is unreachable from sibling routes outside that layout.
8Does deep linking to a modal route work without extra configuration?
Yes, Expo Router derives the linking configuration automatically from the folder structure and reconstructs both the underlying tab screen and the modal screen in front of it when a link is opened, without maintaining a manual linking table.
9What most commonly causes double headers in nested layouts?
When both a parent and a child layout independently set headerShown: true, both stack levels render their own header on top of each other. The fix is to consistently enable the header on exactly one level.
10At what nesting depth does render overhead become noticeable?
Around five or more stack levels nested inside one another, the extra overhead becomes measurable in practice. A deliberately flat structure with clearly separated route groups instead of deep folder hierarchies is usually the more robust solution.