Skip to content

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

FixDevs · (Updated: )

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

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.tsx files, group routes (name), and dynamic [param] segments. The imperative API was useRouter() and useSearchParams.
  • Expo Router v2 (August 2023) — added typed routes. The expo-router plugin can generate a typedRoutes declaration so router.push('/user/[id]') is type-checked. Replaced useSearchParams with the more explicit useLocalSearchParams / useGlobalSearchParams split.
  • Expo Router v3 (February 2024) — shipped with Expo SDK 50. Added API routes (+api.ts files) for web targets, server-side rendering for the web build, and the unstable unstable_settings export 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 origin plugin option, and the useNavigationContainerRef hook 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 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.

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.

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