Skip to content

Web Development · Mobile

Expo Router in 2026: File-Based Navigation That Makes React Native Feel Modern

Expo Router brings file-based routing to React Native, the same pattern web developers know from Next.js. Here's how it works, what it gets right, and where the friction still lives.

Anurag Verma

Anurag Verma

8 min read

Expo Router in 2026: File-Based Navigation That Makes React Native Feel Modern

Sponsored

Share

React Native navigation has always been the part of mobile development that web developers struggle with most. React Navigation works, but it’s explicit: you define a navigator, list the screens, configure transitions, wire up deep linking, and handle the stack state manually. After building a few apps, it becomes manageable. For the first few projects, it’s genuinely confusing.

Expo Router treats navigation like a file system, which is the pattern web developers already know. A file at app/profile/[id].tsx is the screen at /profile/:id. A file at app/(tabs)/home.tsx is a tab screen. Deep links, URL structure, and navigation state follow from the file structure rather than configuration.

This post covers how Expo Router actually works, what the file structure means for navigation, and where the approach still has rough edges.

The Setup

Expo Router is part of the Expo ecosystem and works with the managed workflow (Expo Go, EAS Build) or bare React Native projects:

npx create-expo-app@latest my-app --template tabs
cd my-app
npm install
npx expo start

The tabs template starts you with a working tab navigation setup. The project structure looks like:

app/
  (tabs)/
    _layout.tsx    — defines the tab bar
    index.tsx      — first tab (Home)
    explore.tsx    — second tab
  _layout.tsx      — root layout, wraps everything
  +not-found.tsx   — 404 screen
assets/
components/
constants/

Every file inside app/ that exports a default React component becomes a navigable screen. The file path is the route.

File Conventions

Expo Router uses a few special filename patterns:

_layout.tsx — defines a layout that wraps all screens in the same directory. Use this for tab bars, drawer navigators, stack headers, and shared providers.

(group)/ — parenthesized folder names create route groups. The group name doesn’t appear in the URL. (tabs)/home.tsx maps to /home, not /(tabs)/home.

[param].tsx — dynamic segments. app/users/[id].tsx matches /users/123, /users/abc, etc.

[...rest].tsx — catch-all segments. app/docs/[...path].tsx matches /docs/getting-started, /docs/api/components, etc.

+not-found.tsx — shown when no other route matches.

+html.tsx — for web-specific HTML customization (Expo Router supports web targets).

Building a Tabbed App

Here’s a complete tab layout with two tabs:

// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';

export default function TabLayout() {
  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: '#007AFF',
        headerShown: false,
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: 'Home',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="home" color={color} size={size} />
          ),
        }}
      />
      <Tabs.Screen
        name="profile"
        options={{
          title: 'Profile',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="person" color={color} size={size} />
          ),
        }}
      />
    </Tabs>
  );
}
// app/(tabs)/index.tsx
import { View, Text, Pressable } from 'react-native';
import { Link } from 'expo-router';

export default function HomeScreen() {
  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <Text>Home</Text>
      {/* Link component works like <a> on the web */}
      <Link href="/users/123" asChild>
        <Pressable>
          <Text>Go to User 123</Text>
        </Pressable>
      </Link>
    </View>
  );
}

The Link component handles navigation without needing access to a navigation object. Same API as Next.js’s Link.

Dynamic Routes and Params

// app/users/[id].tsx
import { View, Text } from 'react-native';
import { useLocalSearchParams } from 'expo-router';

export default function UserScreen() {
  const { id } = useLocalSearchParams<{ id: string }>();
  
  return (
    <View>
      <Text>User ID: {id}</Text>
    </View>
  );
}

Navigate programmatically using the router object:

import { router } from 'expo-router';

// Navigate to a route
router.push('/users/123');

// Replace (no back stack entry)
router.replace('/login');

// Go back
router.back();

// Navigate with typed params
router.push({ pathname: '/users/[id]', params: { id: '123' } });

The typed version catches typos at build time if you use TypeScript with Expo Router’s generated types. Run npx expo-env to generate types for your route structure.

Nested Layouts: Stack Inside a Tab

A common pattern is a tab bar with multiple screens per tab (a stack inside each tab). Here’s how that nests:

app/
  (tabs)/
    _layout.tsx           — tab navigator
    (home)/
      _layout.tsx         — stack navigator inside "home" tab
      index.tsx           — home feed
      post/[id].tsx       — individual post detail
    profile.tsx           — profile tab (no nested stack)
// app/(tabs)/(home)/_layout.tsx
import { Stack } from 'expo-router';

export default function HomeStackLayout() {
  return (
    <Stack>
      <Stack.Screen name="index" options={{ title: 'Feed' }} />
      <Stack.Screen name="post/[id]" options={{ title: 'Post' }} />
    </Stack>
  );
}

Now tapping “Back” from the post detail screen returns to the feed, and the tab bar stays visible throughout. The nesting handles the state automatically.

Authentication Gates

A common need: redirect unauthenticated users to a login screen. Expo Router handles this with a root layout and conditional rendering:

// app/_layout.tsx
import { useEffect } from 'react';
import { Stack, useRouter, useSegments } from 'expo-router';
import { useAuth } from '../hooks/useAuth';

export default function RootLayout() {
  const { user, isLoading } = useAuth();
  const router = useRouter();
  const segments = useSegments();

  useEffect(() => {
    if (isLoading) return;
    
    const inAuthGroup = segments[0] === '(auth)';

    if (!user && !inAuthGroup) {
      // Not signed in, redirect to login
      router.replace('/(auth)/login');
    } else if (user && inAuthGroup) {
      // Signed in, redirect to app
      router.replace('/(tabs)');
    }
  }, [user, isLoading, segments]);

  return (
    <Stack>
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      <Stack.Screen name="(auth)" options={{ headerShown: false }} />
    </Stack>
  );
}
app/
  _layout.tsx           — root: handles auth redirect
  (tabs)/               — protected screens
    _layout.tsx
    index.tsx
  (auth)/               — public screens
    _layout.tsx
    login.tsx
    signup.tsx

The useSegments() hook gives you the current route path as an array of segments, which you use to determine which group the user is in.

Deep Linking Without Configuration

One of Expo Router’s genuine advantages over manual React Navigation setup is deep linking. In React Navigation, you write a linking config object that maps URL patterns to screen names. It’s tedious for anything more than a few screens.

With Expo Router, deep links work automatically because the URL structure is the file structure. Configure the scheme in app.json:

{
  "expo": {
    "scheme": "myapp"
  }
}

Then myapp://users/123 opens the app/users/[id].tsx screen with id="123". Universal links (HTTPS URLs that open the app) require an associated domain setup, but the routing configuration is already done.

For web targets (npx expo start --web), the same routes work as real URLs. The same app file renders in the browser with proper URL structure.

What’s Still Rough

Expo Router is genuinely useful, but there are places where the abstraction leaks.

Complex navigation flows are harder to trace. When navigation logic is spread across _layout.tsx files and useEffect hooks, debugging “why is the app navigating to X” requires tracing through multiple files. React Navigation’s explicit configuration is verbose but centralized.

Typed routes have caveats. The generated expo-env types help, but route parameters passed via useLocalSearchParams are always strings. A param like id=123 gives you "123", not 123. This bites TypeScript users who forget to parse.

Modals and sheets. Presenting a modal (a screen that slides up, has its own header, can be dismissed) works via presentation: 'modal' in screen options, but the interactions with the routing state can feel awkward when you want modal-like navigation that doesn’t add to the URL.

Performance on deep stacks. Expo Router renders all screens in a navigator at startup (for performance), which means complex apps need to be thoughtful about heavy initialization in screen components.

Comparing to React Navigation

Expo Router is built on React Navigation. They’re not alternatives in the sense that one replaces the other; Expo Router is an opinionated layer on top that handles routing configuration automatically.

React NavigationExpo Router
Config styleExplicit navigator + screen definitionsFile system
Deep linkingManual linking configAutomatic from file structure
Web supportLimited (community packages)First-class
Universal linksSeparate configSame routing, scheme config in app.json
Escape hatchesFull API accessFull React Navigation API underneath
Best forComplex app-specific navigation flowsStandard app structures, universal apps

If your app has unusual navigation requirements (non-linear flows, branching onboarding, game-like navigation), React Navigation’s explicit API gives you more control. If you’re building a standard app (tabs, stacks, modals) or want native + web from the same codebase, Expo Router’s defaults get you there faster.

Getting There from an Existing React Navigation App

Expo Router and React Navigation coexist. You can migrate one section of your app at a time by adopting Expo Router for new screens while keeping existing React Navigation code intact. The Expo Router documentation has a migration guide, but the short version:

  1. Add Expo Router to your project
  2. Move new screens into the app/ directory with Expo Router structure
  3. Keep existing React Navigation navigators for parts you haven’t migrated
  4. Gradually move existing screens into app/ as you touch them

It’s not a weekend migration for a large app, but the incremental path is real.

For new projects in 2026, Expo Router is the default unless you have specific reasons to go with raw React Navigation. The conventions it provides are the same ones web developers already know, and the deep linking and web support you’d otherwise configure manually comes for free.

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored