Expo Router navigation patterns I use in every app
← Back
April 4, 2026React Native8 min read

Expo Router navigation patterns I use in every app

Published April 4, 20268 min read

Expo Router brings file-based routing to React Native, the same model that Next.js made standard for the web. Once you understand how the file structure maps to navigation, you stop fighting the router and start building features. Here are the patterns I reach for in every app: nested layouts, tab navigation, protected routes, and modals.

The file structure that makes sense

Expo Router uses the app/ directory. Files become routes, folders become nested navigators. Here is a typical app structure:

text
app/
  _layout.tsx          — root layout (providers, fonts, splash)
  (auth)/
    _layout.tsx        — auth stack navigator
    login.tsx          — /login
    register.tsx       — /register
  (app)/
    _layout.tsx        — tab navigator (only for authenticated users)
    index.tsx          — / (home tab)
    explore.tsx        — /explore tab
    profile/
      _layout.tsx      — profile stack
      index.tsx        — /profile
      edit.tsx         — /profile/edit
  +not-found.tsx       — 404 screen

The parenthesised folders (auth) and (app) are route groups — they create nested navigators without adding segments to the URL. (auth)/login is just /login, not /auth/login.

Root layout with providers

typescript
// app/_layout.tsx
import { Stack } from 'expo-router';
import { useFonts } from 'expo-font';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from '../contexts/AuthContext';

SplashScreen.preventAutoHideAsync();

const queryClient = new QueryClient();

export default function RootLayout() {
  const [fontsLoaded] = useFonts({
    'Inter-Regular': require('../assets/fonts/Inter-Regular.ttf'),
    'Inter-SemiBold': require('../assets/fonts/Inter-SemiBold.ttf'),
  });

  useEffect(() => {
    if (fontsLoaded) {
      SplashScreen.hideAsync();
    }
  }, [fontsLoaded]);

  if (!fontsLoaded) return null;

  return (
    <QueryClientProvider client={queryClient}>
      <AuthProvider>
        <Stack screenOptions={{ headerShown: false }} />
      </AuthProvider>
    </QueryClientProvider>
  );
}

Protected routes with redirect

The cleanest pattern for auth-gating uses a layout component that checks auth state and redirects:

typescript
// app/(app)/_layout.tsx
import { Tabs, Redirect } from 'expo-router';
import { useAuth } from '../../contexts/AuthContext';
import { Ionicons } from '@expo/vector-icons';

export default function AppLayout() {
  const { user, isLoading } = useAuth();

  if (isLoading) return null; // or a splash screen

  if (!user) {
    return <Redirect href="/login" />;
  }

  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: '#6366f1',
        tabBarInactiveTintColor: '#9ca3af',
        headerShown: false,
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: 'Home',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="home" size={size} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="explore"
        options={{
          title: 'Explore',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="search" size={size} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="profile"
        options={{
          title: 'Profile',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="person" size={size} color={color} />
          ),
        }}
      />
    </Tabs>
  );
}

Modal routes

Modals are a special case — they sit outside the tab navigator and overlay the current screen. Define them in the root layout:

typescript
// app/_layout.tsx (root stack, extended)
<Stack screenOptions={{ headerShown: false }}>
  <Stack.Screen name="(auth)" />
  <Stack.Screen name="(app)" />
  <Stack.Screen
    name="modal/create-post"
    options={{
      presentation: 'modal',
      headerShown: true,
      title: 'New Post',
    }}
  />
</Stack>
typescript
// Opening the modal from anywhere in the app
import { router } from 'expo-router';

function SomeComponent() {
  return (
    <Button
      title="Create Post"
      onPress={() => router.push('/modal/create-post')}
    />
  );
}

Typed navigation with params

Expo Router supports typed routes when you enable typedRoutes in app.json. For passing params:

typescript
// Navigate with params
router.push({
  pathname: '/profile/[id]',
  params: { id: user.id },
});

// Receive params in the screen
import { useLocalSearchParams } from 'expo-router';

export default function ProfileScreen() {
  const { id } = useLocalSearchParams<{ id: string }>();
  // id is always a string — parse if needed
}

The back button and stack manipulation

typescript
import { router } from 'expo-router';

// Go back
router.back();

// Go back to a specific route (replaces history)
router.replace('/home');

// Dismiss a modal
router.dismiss();

// Check if you can go back before calling back()
if (router.canGoBack()) {
  router.back();
} else {
  router.replace('/home');
}

Common mistakes

The biggest one I see: putting useAuth() in every screen instead of checking auth in the layout. The layout check runs once and protects all child routes. Per-screen checks lead to a flash of protected content while the check runs.

The second: not using route groups for visual grouping. Every tab section, every modal group, every flow should be in its own route group with its own _layout.tsx. This keeps the root layout clean and makes the navigation structure scannable at a glance.

File-based routing felt limiting when I first switched from React Navigation. After a few months, I can not imagine going back to a manually defined navigation tree.

Share this
← All Posts8 min read