Expo Router nested layouts explained — route groups, layout chains, and the files that actually run
Expo Router nested layouts explained — route groups, layout chains, and the files that actually run
From
app/_layout.tsxtoapp/(tabs)/settings/_layout.tsx— the full layout chain model with real file paths and route derivation rules.
Most Expo Router tutorials explain how to create a _layout.tsx. Few explain what happens when you have three of them stacked — and which one renders first, which one wraps which, and what "route group" actually means at runtime versus at the URL level.
This boilerplate has 22 screens across four layout files and three route groups. Here is the exact structure, how it was reasoned about, and where the decisions were close calls.
What Expo Router builds from the filesystem
Expo Router derives routes from the app/ directory. The rules that matter:
- Every file that is not
_layout.tsx,+not-found.tsx, or+html.tsxbecomes a navigable route. - Directories wrapped in parentheses —
(auth),(tabs)— are route groups. The group name is stripped from the URL.app/(auth)/login.tsxproduces route/login, not/auth/login. _layout.tsxinside a group defines the layout wrapper for every screen in that group. It is not itself a navigable route.- Nested directories inside a route group produce nested routes.
app/(tabs)/settings/change-email.tsxproduces/settings/change-email.
The registry.index.json in this boilerplate captures the derived route and the full layoutChain for every screen — the ordered list of layout files that wrap it, outermost first:
{
"filePath": "app/(tabs)/settings/change-email.tsx",
"route": "/settings/change-email",
"kind": "screen",
"group": "tabs",
"layoutChain": [
"app/_layout.tsx",
"app/(tabs)/_layout.tsx",
"app/(tabs)/settings/_layout.tsx"
]
}
Three layouts wrap this one screen. The root layout runs first, then the tabs layout, then the settings layout, then the screen component. Understanding that stack is the difference between "why is my header appearing twice" and shipping a clean UI.
The four layout files and their jobs
app/_layout.tsx — root
Runs before anything else. Its job in this boilerplate: load fonts, restore the user session from SecureStore, render <Slot /> (which is the placeholder for all child routes), and mount the global <Toaster />. This is the only layout that talks to SecureStore. No group layout has any business running network calls.
// app/_layout.tsx
export default function RootLayout() {
const [loaded] = useFonts({});
const setUser = useAuthStore((s) => s.setUser);
const setAuthenticated = useAuthStore((s) => s.setAuthenticated);
useEffect(() => {
async function restoreSession() {
const token = await getToken("accessToken");
if (!token) { setAuthenticated(false); return; }
const user = await AuthClientService.getSession();
setUser(user);
}
restoreSession();
}, [setUser, setAuthenticated]);
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<Slot />
<Toaster position="bottom-center" />
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
<Slot /> is the render point for child routes. Every group layout below this renders inside <Slot />.
app/(auth)/_layout.tsx — auth gate
Reads isAuthenticated from the Zustand store. If the user is already authenticated, redirects to /. Otherwise renders a <Stack> with all six auth screens as named Stack.Screen entries. The headerShown: false option suppresses the default React Navigation header — all auth screens have custom headers or none at all.
// app/(auth)/_layout.tsx
export default function AuthLayout() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
if (isAuthenticated) return <Redirect href="/" />;
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="login" />
<Stack.Screen name="register" />
<Stack.Screen name="2fa" />
<Stack.Screen name="select-tenant" />
<Stack.Screen name="forgot-password" />
</Stack>
);
}
This layout does not appear in the layoutChain for screens in the (tabs) group — route groups are independent. The (auth) layout only wraps (auth) screens.
app/(tabs)/_layout.tsx — tab bar
Renders a <Tabs> component (React Navigation bottom tabs under the hood). Defines the visible tab entries — home, notifications, settings — with icons and labels. Screens inside (tabs) that are not registered as <Tabs.Screen> entries are still accessible via router.push() but do not appear in the tab bar.
app/(tabs)/settings/_layout.tsx — settings stack
A nested <Stack> inside the tabs group. The settings section has five screens: index (the settings list), profile, change-email, change-language, sessions, and two tenant management screens. Using a nested <Stack> here gives these screens standard push/pop navigation with a back arrow, separate from the root stack and the auth stack.
The route derivation rules in practice
Route groups strip the group name. This is why select-tenant is navigable at /select-tenant, not /auth/select-tenant:
app/(auth)/select-tenant.tsx → /select-tenant
app/(tabs)/index.tsx → /
app/(tabs)/settings/index.tsx → /settings
app/(tabs)/settings/tenant/invitations.tsx → /settings/tenant/invitations
The (tabs) directory is stripped. The settings/ directory is kept. The tenant/ subdirectory inside settings is also kept. Route groups only strip their own name — subdirectories without parentheses become URL segments.
The full screen list in this boilerplate, with their resolved routes:
app/_layout.tsx → (layout, no route)
app/(auth)/_layout.tsx → (layout, no route)
app/(auth)/login.tsx → /login
app/(auth)/register.tsx → /register
app/(auth)/2fa.tsx → /2fa
app/(auth)/forgot-password.tsx → /forgot-password
app/(auth)/create-tenant.tsx → /create-tenant
app/(auth)/select-tenant.tsx → /select-tenant
app/(tabs)/_layout.tsx → (layout, no route)
app/(tabs)/index.tsx → /
app/(tabs)/notifications.tsx → /notifications
app/(tabs)/settings/_layout.tsx → (layout, no route)
app/(tabs)/settings/index.tsx → /settings
app/(tabs)/settings/profile.tsx → /settings/profile
app/(tabs)/settings/change-email.tsx → /settings/change-email
app/(tabs)/settings/change-language.tsx → /settings/change-language
app/(tabs)/settings/sessions.tsx → /settings/sessions
app/(tabs)/settings/tenant/index.tsx → /settings/tenant
app/(tabs)/settings/tenant/members.tsx → /settings/tenant/members
app/(tabs)/settings/tenant/invitations.tsx → /settings/tenant/invitations
app/+not-found.tsx → (404 handler)
app/+html.tsx → (web HTML shell)
22 file entries. 18 navigable routes. 4 layout files. 0 manually configured route maps.
The thing that almost went differently
The settings screens started inside (tabs)/ without their own _layout.tsx. That worked fine for the settings list and profile screens. When the tenant management sub-section was added — three more screens with their own back-navigation stack — the flat structure started producing incorrect headers and navigation state. Adding app/(tabs)/settings/_layout.tsx with a <Stack> wrapper gave those screens clean push/pop navigation without affecting the tab bar behavior of the parent group.
The alternative was to move tenant screens into a separate route group — (tenant)/ or similar. That would have required a third group layout and made the URL structure less predictable. The nested _layout.tsx inside an existing group kept the URL structure flat (/settings/tenant/members) while giving the navigation stack the right structure.
What you would change if starting over
The select-tenant and create-tenant screens live in (auth)/. That made sense initially because they are part of the post-login flow. It becomes awkward if you later want to show a "switch workspace" option from inside the app — you cannot route to /select-tenant from within (tabs) without the (auth) layout's authentication guard potentially redirecting you away. A better structure would put tenant selection in a separate (workspace)/ group that is accessible regardless of authentication status, with its own guard that allows access only when a valid session exists.
Trade-off
Route groups and nested layouts are powerful but they add implicit wrapping that is invisible in the URL. A screen at /settings/profile has three layout files running before the screen component — root, tabs, settings stack. If any of those layouts does something expensive (a network call, a large context provider) every screen in that chain pays the cost. The convention in this boilerplate is: layouts are cheap wrappers (redirect gates, navigation containers, font loading). All data fetching happens inside the screen component, not in layouts.
Business impact
Navigation structure is the skeleton of a mobile app. Getting it right before the third screen is added is cheap. Refactoring it after a dozen screens exist — updating every router.push() call, reconciling deep-link configurations, adjusting the navigation state for every Jest test that renders a screen — is expensive. The file-based model in Expo Router makes the structure auditable at a glance: the app/ directory is the route map. New engineers understand where a screen lives without reading navigation configuration files.
What to do next
If you are adding a new section to an existing Expo Router app, check whether it needs its own _layout.tsx before creating the first screen. The test: will these screens share navigation behavior (back arrows, header style, transition animation) that differs from the parent group? If yes, add the _layout.tsx first. If not, the parent group layout covers them. A _layout.tsx added later requires re-checking every screen in the group for layout chain changes — easier to decide upfront.
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox