Skip to content

Fix: Expo Router Not Working — Routes Not Matching, Layout Nesting Wrong, or Deep Links Failing

FixDevs ·

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 content

Or router.push() navigates but the back button doesn’t work correctly:

router.push('/details/123');
// Screen shows but pressing back goes to the wrong screen

Why This Happens

Expo Router uses file-system routing for React Native, similar to Next.js App Router:

  • File names map directly to routesapp/settings.tsx creates the /settings route. A misplaced file (e.g., src/settings.tsx outside app/) won’t register.
  • _layout.tsx defines the navigation structure — layouts wrap child routes. A _layout.tsx with <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] syntaxapp/user/[id].tsx matches /user/123. The brackets are part of the filename, not optional.

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 screenrouter.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.

For related mobile issues, see Fix: Expo Not Working and Fix: NativeWind Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles