Structuring navigation in a real app: nested stacks done right
· 3 min read · Amrith Vengalath
- React Native
- React Navigation
- Mobile app development
Every React Navigation tutorial shows you a single stack with two screens, and then you build a real app and immediately need something more complicated: a bottom tab bar where each tab keeps its own back history, a login flow that should sit completely outside the tabs, and the occasional modal that floats over everything. Fitting those together isn't hard, but the structure matters, and getting it wrong leads to the kind of bugs where the back button does something baffling.
Here's the layout I keep coming back to.
Think in layers, not screens
The mistake I made early was treating navigation as a flat list of screens. It's not - it's nested containers, each with a job:
- An outermost switch between "logged out" and "logged in." These are different worlds.
- Inside "logged in," a tab navigator.
- Inside each tab, its own stack so each tab remembers where you were.
- Modals layered over the whole thing.
That nesting is the design. Once you see it as layers, the code falls out naturally.
The auth split at the top
The top level isn't a screen you navigate to - it's a conditional. If there's no user, render the auth stack; if there is, render the app. You don't "navigate to login"; login simply doesn't exist as a destination once you're authenticated.
function RootNavigator() {
const { user } = useAuth();
return (
<NavigationContainer>
{user ? <AppTabs /> : <AuthStack />}
</NavigationContainer>
);
}The nice property here is that logging out doesn't need any navigation logic. You clear the user, the whole tree swaps to the auth stack, and there's no stale app screen lurking in a back stack behind the login screen. I've seen people try to navigate("Login") on logout and then fight the leftover history. Don't. Let the conditional do it.
Tabs, each with its own stack
Each tab gets a stack navigator, not a bare screen. That's what gives you per-tab history - tap into a product from the Home tab, switch to the Cart tab and back, and Home is still showing the product where you left it.
function HomeStack() {
return (
<Stack.Navigator>
<Stack.Screen name="Feed" component={FeedScreen} />
<Stack.Screen name="Product" component={ProductScreen} />
</Stack.Navigator>
);
}
function AppTabs() {
return (
<Tab.Navigator screenOptions={{ headerShown: false }}>
<Tab.Screen name="HomeTab" component={HomeStack} />
<Tab.Screen name="CartTab" component={CartStack} />
<Tab.Screen name="ProfileTab" component={ProfileStack} />
</Tab.Navigator>
);
}Note headerShown: false on the tab navigator - you want the stacks to own the headers, not the tab bar. Otherwise you get two stacked headers, which looks exactly as bad as it sounds.
Modals belong above the tabs
A modal that should cover the tab bar - a full-screen image viewer, a "create post" sheet - can't live inside a tab's stack, or the tab bar stays visible underneath. Put it in a stack that wraps the tabs, with a modal presentation:
<RootStack.Navigator screenOptions={{ presentation: "modal", headerShown: false }}>
<RootStack.Screen name="Tabs" component={AppTabs} />
<RootStack.Screen name="ImageViewer" component={ImageViewerScreen} />
</RootStack.Navigator>Now navigate("ImageViewer") from anywhere slides up over the entire app, tab bar included.
Typing it so navigation stops lying to you
If you're on TypeScript, define your param lists. The first time a screen crashed because I passed productId as a number and read it as a string, I started typing every stack:
type HomeStackParams = {
Feed: undefined;
Product: { productId: string };
};It turns navigation.navigate and route.params into something the compiler checks, and a whole class of "undefined is not an object" runtime errors just stops happening.
The shape that scales
The pattern, top to bottom: auth-vs-app conditional, then a root stack for modals, then tabs, then a stack per tab. It looks like more boilerplate than the two-screen demo, but it's the structure that survives a real product with a dozen screens, and it keeps the back button behaving the way users expect - which, honestly, is most of the battle.