Fix: Expo Router Not Working — Routes Not Matching, Layout Nesting Wrong, or Deep Links Failing
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix Expo Router issues — file-based routing, layout routes, dynamic segments, tabs and stack navigation, modal routes, authentication flows, and deep linking configuration.
The Problem
A route file exists but navigating to it shows “Unmatched Route”:
app/
├── _layout.tsx
├── index.tsx # /
└── settings.tsx # /settings → "Unmatched Route"Or tab navigation doesn’t render the correct screens:
// Tabs render but tapping a tab shows blank contentOr router.push() navigates but the back button doesn’t work correctly:
router.push('/details/123');
// Screen shows but pressing back goes to the wrong screenWhy This Happens
Expo Router uses file-system routing for React Native, similar to Next.js App Router:
- File names map directly to routes —
app/settings.tsxcreates the/settingsroute. A misplaced file (e.g.,src/settings.tsxoutsideapp/) won’t register. _layout.tsxdefines the navigation structure — layouts wrap child routes. A_layout.tsxwith<Stack>creates stack navigation. With<Tabs>, it creates tab navigation. Missing or misconfigured layouts cause blank screens.- Group routes
(name)don’t affect the URL — directories wrapped in parentheses like(tabs)or(auth)are organizational — they don’t add URL segments. But they must have their own_layout.tsx. - Dynamic segments use
[param]syntax —app/user/[id].tsxmatches/user/123. The brackets are part of the filename, not optional.
Expo Router sits on top of React Navigation, but it inverts the relationship developers are used to. With plain React Navigation, you imperatively register screens in a Stack.Navigator and the router exists in code. With Expo Router, the file system is the navigator config — the bundler scans app/, builds the route tree at build time, and the runtime just resolves URLs against that tree. That means most “Unmatched Route” errors are not runtime bugs; they’re build-time errors where a file didn’t get picked up. The route tree generates from filename conventions, so a typo in a filename produces the same symptom as a missing file.
The layout nesting model is the second source of confusion. Each _layout.tsx declares what kind of navigator wraps its children — Stack, Tabs, Drawer, or a custom navigator. A child route inherits the closest layout. If you put a Tabs layout at the root and a Stack layout inside a group, navigating to a screen inside the stack still keeps the tab bar visible. Forgetting to wrap a group with its own layout is the usual cause of “the tab bar shows on screens where it shouldn’t” complaints. The fix is almost always to add a _layout.tsx to that directory, not to fiddle with screen options.
Expo Router Version History
Knowing which version a tutorial targets is critical because the API has evolved quickly:
- Expo Router v1 (July 2023) — first stable release, shipped with Expo SDK 49. Established the
app/directory convention,_layout.tsxfiles, group routes(name), and dynamic[param]segments. The imperative API wasuseRouter()anduseSearchParams. - Expo Router v2 (August 2023) — added typed routes. The
expo-routerplugin can generate atypedRoutesdeclaration sorouter.push('/user/[id]')is type-checked. ReplaceduseSearchParamswith the more explicituseLocalSearchParams/useGlobalSearchParamssplit. - Expo Router v3 (February 2024) — shipped with Expo SDK 50. Added API routes (
+api.tsfiles) for web targets, server-side rendering for the web build, and the unstableunstable_settingsexport for per-screen configuration. Also introduced typed route param helpers. - Expo Router v4 (November 2024) — shipped with Expo SDK 52. Added async routes (lazy loading via React Suspense), improved deep linking with the
originplugin option, and theuseNavigationContainerRefhook for advanced integrations. The dev server gained a route graph visualizer.
The practical impact is that an app/settings.tsx example from a 2023 tutorial will look identical to one from 2025, but the supporting APIs around it (typed routes, API routes, async routes) only exist on the matching version. When debugging, run npx expo-doctor or check package.json for the expo-router version, then match the documentation to that line. Mixing v3 docs with a v1 project is a frequent root cause of “but I copied it exactly.”
Expo SDK and Expo Router version together. SDK 49 expects Router v1, SDK 50 expects v3, SDK 52 expects v4. Bumping one without the other introduces subtle bugs — for example, SDK 50 + Router v1 will install but app.json plugin options like typedRoutes will silently no-op. The safest upgrade path is npx expo install expo-router (which picks the SDK-matched version) instead of installing a specific version directly.
Fix 1: Basic File Structure
app/
├── _layout.tsx # Root layout (Stack navigator)
├── index.tsx # / (home screen)
├── about.tsx # /about
├── settings.tsx # /settings
├── (tabs)/
│ ├── _layout.tsx # Tab layout
│ ├── index.tsx # / (home tab)
│ ├── explore.tsx # /explore (explore tab)
│ └── profile.tsx # /profile (profile tab)
├── user/
│ ├── _layout.tsx # Stack for user routes
│ ├── [id].tsx # /user/123 (dynamic)
│ └── edit.tsx # /user/edit
├── (auth)/
│ ├── _layout.tsx # Auth layout (no tabs)
│ ├── login.tsx # /login
│ └── register.tsx # /register
└── modal.tsx # Modal route// app/_layout.tsx — root layout
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen
name="modal"
options={{ presentation: 'modal', title: 'Modal' }}
/>
</Stack>
);
}Fix 2: Tab Navigation
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#3b82f6',
tabBarInactiveTintColor: '#9ca3af',
tabBarStyle: {
backgroundColor: '#ffffff',
borderTopColor: '#e5e7eb',
},
headerStyle: { backgroundColor: '#ffffff' },
headerShadowVisible: false,
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => (
<Ionicons name="home-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="explore"
options={{
title: 'Explore',
tabBarIcon: ({ color, size }) => (
<Ionicons name="compass-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => (
<Ionicons name="person-outline" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
// app/(tabs)/index.tsx — home tab
import { View, Text } from 'react-native';
export default function HomeScreen() {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text>Home Screen</Text>
</View>
);
}Fix 3: Navigation and Dynamic Routes
// app/user/[id].tsx — dynamic route
import { useLocalSearchParams, Stack } from 'expo-router';
import { View, Text } from 'react-native';
export default function UserProfile() {
const { id } = useLocalSearchParams<{ id: string }>();
return (
<View style={{ flex: 1, padding: 16 }}>
<Stack.Screen options={{ title: `User ${id}` }} />
<Text>User ID: {id}</Text>
</View>
);
}// Navigate programmatically
import { router, Link } from 'expo-router';
function NavigationExamples() {
return (
<View>
{/* Link component */}
<Link href="/about">About</Link>
<Link href="/user/123">User 123</Link>
<Link href={{ pathname: '/user/[id]', params: { id: '456' } }}>User 456</Link>
{/* Push (adds to stack) */}
<Button title="Go to Settings" onPress={() => router.push('/settings')} />
{/* Replace (replaces current screen) */}
<Button title="Replace" onPress={() => router.replace('/home')} />
{/* Back */}
<Button title="Go Back" onPress={() => router.back()} />
{/* Navigate (smart — push or back depending on history) */}
<Button title="Navigate" onPress={() => router.navigate('/explore')} />
{/* With params */}
<Button
title="Open User"
onPress={() => router.push({ pathname: '/user/[id]', params: { id: '789' } })}
/>
{/* Open modal */}
<Button title="Open Modal" onPress={() => router.push('/modal')} />
{/* Dismiss modal */}
<Button title="Close" onPress={() => router.dismiss()} />
</View>
);
}Fix 4: Authentication Flow
// app/(auth)/_layout.tsx
import { Stack } from 'expo-router';
export default function AuthLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="login" />
<Stack.Screen name="register" />
</Stack>
);
}
// app/_layout.tsx — redirect based on auth state
import { Stack, Redirect } from 'expo-router';
import { useAuth } from '@/hooks/useAuth';
export default function RootLayout() {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) return <LoadingScreen />;
return (
<Stack>
{isAuthenticated ? (
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
) : (
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
)}
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
</Stack>
);
}
// Or use Redirect component in individual screens
// app/(tabs)/index.tsx
import { Redirect } from 'expo-router';
import { useAuth } from '@/hooks/useAuth';
export default function HomeScreen() {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) return <Redirect href="/login" />;
return <View>...</View>;
}Fix 5: Deep Linking
// app.json — configure deep linking
{
"expo": {
"scheme": "myapp",
"web": {
"bundler": "metro"
},
"plugins": [
[
"expo-router",
{
"origin": "https://myapp.com"
}
]
]
}
}// Deep links map to file routes:
// myapp://settings → app/settings.tsx
// myapp://user/123 → app/user/[id].tsx
// https://myapp.com/about → app/about.tsx
// Handle deep links in code
import { useURL } from 'expo-linking';
import { router } from 'expo-router';
import { useEffect } from 'react';
function DeepLinkHandler() {
const url = useURL();
useEffect(() => {
if (url) {
// Expo Router handles most deep links automatically
// Custom handling for specific cases:
const path = new URL(url).pathname;
if (path.startsWith('/invite/')) {
const code = path.replace('/invite/', '');
handleInvite(code);
}
}
}, [url]);
return null;
}Fix 6: API Routes (Server Functions)
// app/api/users+api.ts — API route (Expo web only)
export async function GET(request: Request) {
const users = await db.query.users.findMany();
return Response.json(users);
}
export async function POST(request: Request) {
const body = await request.json();
const user = await db.insert(users).values(body).returning();
return Response.json(user[0], { status: 201 });
}
// app/api/users/[id]+api.ts — dynamic API route
export async function GET(request: Request, { id }: { id: string }) {
const user = await db.query.users.findFirst({ where: eq(users.id, id) });
if (!user) return Response.json({ error: 'Not found' }, { status: 404 });
return Response.json(user);
}Still Not Working?
“Unmatched Route” — the file doesn’t exist in the app/ directory or the filename is wrong. Check: app/settings.tsx creates /settings, app/user/[id].tsx creates /user/:id. Files outside app/ are not routes.
Tab shows blank screen — the Tabs.Screen name prop must match the file name exactly. <Tabs.Screen name="index" /> maps to app/(tabs)/index.tsx. A mismatch between the name and the filename shows nothing.
Back navigation goes to wrong screen — router.push() always adds to the stack. Use router.replace() to swap the current screen, or router.navigate() which intelligently goes back if the route is already in the stack.
Layouts not nesting correctly — each directory that contains routes needs its own _layout.tsx. Group directories (name) also need layouts. If a layout is missing, child routes may not render or may skip the intended navigation structure.
Typed routes complain about valid paths — typed routes are generated by Expo’s babel plugin into expo-env.d.ts. If routes get out of sync (you renamed a file and TypeScript still complains), restart the dev server with npx expo start --clear to regenerate the route types. Make sure typedRoutes: true is set in the expo-router plugin block of app.json.
Deep link opens app but lands on the wrong screen — Expo Router uses the scheme from app.json for custom URLs and the origin plugin option for universal/web links. If myapp://user/123 opens the app at the root instead of the user screen, the linking config can’t see the route tree. Confirm scheme is set, run npx expo prebuild --clean if you’re on the bare workflow, and verify the route file exists with the exact dynamic segment name.
Hot reload breaks after editing _layout.tsx — layout files are special and require a full reload to re-evaluate the navigator tree. If you change a Stack to a Tabs and screens stop rendering, kill the Metro bundler and restart with --clear. The fast-refresh path can’t swap navigators in place.
For related mobile issues, see Fix: Expo Not Working and Fix: NativeWind Not Working. For comparison with the imperative alternative, see Fix: React Navigation Not Working. If the Metro bundler itself is failing before Expo Router can load, see Fix: React Native Metro Bundler Failed.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: React Native Paper Not Working — Theme Not Applying, Icons Missing, or Components Unstyled
How to fix React Native Paper issues — PaperProvider setup, Material Design 3 theming, custom color schemes, icon configuration, dark mode, and Expo integration.
Fix: React Navigation Not Working — Screens Not Rendering, TypeScript Errors, or Gestures Broken
How to fix React Navigation issues — stack and tab navigator setup, TypeScript typing, deep linking, screen options, nested navigators, authentication flow, and performance optimization.
Fix: NativeWind Not Working — Styles Not Applying, Dark Mode Broken, or Metro Bundler Errors
How to fix NativeWind issues — Tailwind CSS for React Native setup, Metro bundler configuration, className prop, dark mode, responsive styles, and Expo integration.
Fix: Tamagui Not Working — Styles Not Applying, Compiler Errors, or Web/Native Mismatch
How to fix Tamagui UI kit issues — setup with Expo, theme tokens, styled components, animations, responsive props, media queries, and cross-platform rendering.