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.
Table of Contents
- 1. Nested _layout files: the core principle of Expo Router
- 2. Sharing common UI elements across multiple route levels
- 3. Controlling modal presentation through file based routing
- 4. Practical example: tab navigation with a modal detail screen
- 5. Route groups: file system organization without touching the URL
- 6. Passing layout specific header configuration down
- 7. Sharing state across nested layout levels
- 8. Deep linking to nested modal routes
- 9. Common pitfalls with nested layouts
- 10. Summary
- 11. FAQ
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.
3. Controlling modal presentation through file based routing
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.