OB
All posts
React Native Navigation Architecture

React Native Navigation: Patterns That Scale

March 20, 20243 min readby MHD Omar Bahra

Why Navigation Gets Messy

Small React Native apps start simple: one stack, a few screens. Then you add tabs. Then auth. Then modals that need to appear from anywhere. Then deep links. Before long your App.tsx is a 300-line navigator nightmare.

The fix isn't a library switch — it's structuring your navigator tree deliberately from the start.

The Root Navigator Pattern

Your root navigator should handle authentication state, nothing else. It renders either the auth flow or the main app — never both.

// RootNavigator.tsx
export function RootNavigator() {
  const { isAuthenticated } = useAuth()

  return (
    <NavigationContainer>
      {isAuthenticated ? <AppNavigator /> : <AuthNavigator />}
    </NavigationContainer>
  )
}

This keeps auth transitions clean and avoids the classic problem of authenticated screens flashing before the redirect.

Structure: Tabs Wrap Stacks, Not the Other Way Around

Tabs should be the outermost shell. Each tab gets its own stack. This preserves navigation history per tab, which is what users expect.

// AppNavigator.tsx
const Tab = createBottomTabNavigator()

export function AppNavigator() {
  return (
    <Tab.Navigator>
      <Tab.Screen name="HomeTab" component={HomeStack} />
      <Tab.Screen name="ProfileTab" component={ProfileStack} />
      <Tab.Screen name="SearchTab" component={SearchStack} />
    </Tab.Navigator>
  )
}

// HomeStack.tsx
const Stack = createNativeStackNavigator()

export function HomeStack() {
  return (
    <Stack.Navigator>
      <Stack.Screen name="Home" component={HomeScreen} />
      <Stack.Screen name="ProductDetail" component={ProductDetailScreen} />
      <Stack.Screen name="Checkout" component={CheckoutScreen} />
    </Stack.Navigator>
  )
}

Modals That Appear From Anywhere

Modals are the hardest part. You want a modal to be triggerable from any screen, but you can't navigate to a modal from inside a nested stack without lifting it to the root.

The solution: put modals at the root level using a separate stack.

// RootStack.tsx
const RootStack = createNativeStackNavigator()

export function AppNavigator() {
  return (
    <RootStack.Navigator screenOptions={{ headerShown: false }}>
      <RootStack.Screen name="Main" component={TabNavigator} />
      {/* These modals are accessible from any screen */}
      <RootStack.Group screenOptions={{ presentation: 'modal' }}>
        <RootStack.Screen name="ImageViewer" component={ImageViewerModal} />
        <RootStack.Screen name="FilterSheet" component={FilterSheetModal} />
      </RootStack.Group>
    </RootStack.Navigator>
  )
}

Deep Linking

Configure deep links once at the NavigationContainer level using a linking config object. Map every URL path to a screen name.

const linking = {
  prefixes: ['myapp://', 'https://myapp.com'],
  config: {
    screens: {
      Main: {
        screens: {
          HomeTab: {
            screens: {
              ProductDetail: 'product/:id',
            },
          },
          ProfileTab: {
            screens: {
              Profile: 'profile/:userId',
            },
          },
        },
      },
      ImageViewer: 'view/:imageId',
    },
  },
}

<NavigationContainer linking={linking}>

React Navigation handles parsing the URL and navigating to the right screen automatically — including through nested navigators.

Type Your Params

Untyped navigation is a maintenance trap. Define a type map for every navigator and use it everywhere.

// types/navigation.ts
export type HomeStackParams = {
  Home: undefined
  ProductDetail: { productId: string; fromDeepLink?: boolean }
  Checkout: { cartId: string }
}

// In your screen component
type Props = NativeStackScreenProps<HomeStackParams, 'ProductDetail'>

export function ProductDetailScreen({ route, navigation }: Props) {
  const { productId } = route.params // fully typed
}

This catches broken navigation calls at compile time and makes refactoring safe.

Navigation Service for Outside-Component Navigation

Sometimes you need to navigate from a Redux action, an API interceptor, or a notification handler — not from inside a component. Use a navigation ref.

// navigationRef.ts
import { createNavigationContainerRef } from '@react-navigation/native'

export const navigationRef = createNavigationContainerRef()

export function navigate(name, params) {
  if (navigationRef.isReady()) {
    navigationRef.navigate(name, params)
  }
}

// App.tsx
<NavigationContainer ref={navigationRef}>

// Anywhere in your app — Redux, API layer, push notification handler
import { navigate } from './navigationRef'
navigate('ProductDetail', { productId: '123' })

Enjoyed this post?

Subscribe to the newsletter

Get future posts delivered to your inbox. No spam, unsubscribe anytime.