Expo Router navigation patterns I use in every app
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:
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
// 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:
// 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:
// 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>
// 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:
// 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
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.