Expo Router in React Native: File-Based Navigation Complete Guide

Master Expo Router for React Native with this complete tutorial covering file-based routing, layouts, dynamic routes, typed navigation, and advanced patterns like modals and tabs in 2026.

Expo Router file-based navigation system for React Native mobile applications

Expo Router brings file-based routing to React Native, replacing manual navigation configuration with a convention-driven approach inspired by Next.js. Starting with Expo SDK 55 and Expo Router v6, building cross-platform navigation for Android, iOS, and web requires nothing more than creating files in the right directory.

Quick Setup

New Expo projects ship with Expo Router pre-configured. Run npx create-expo-app@latest --template default@sdk-55 to start with file-based routing out of the box. Existing projects can add it by installing expo-router and updating the entry point.

How File-Based Routing Works in Expo Router

Every file inside the app directory automatically becomes a route. The file path maps directly to the URL path, eliminating the need for a centralized navigation configuration. A file at app/settings.tsx creates a /settings route, while app/profile/edit.tsx maps to /profile/edit.

This approach offers three key advantages over traditional React Navigation setup:

  • Zero configuration: routes exist the moment a file is created
  • Automatic deep linking: every screen gets a URL, enabling sharing and testing
  • Type-safe navigation: TypeScript knows which routes exist at compile time
app/index.tsxtypescript
import { View, Text, StyleSheet } from 'react-native'
import { Link } from 'expo-router'

export default function HomeScreen() {
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Welcome</Text>
      {/* Link maps directly to file path */}
      <Link href="/settings" style={styles.link}>
        Open Settings
      </Link>
      <Link href="/profile/edit" style={styles.link}>
        Edit Profile
      </Link>
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 24 },
  title: { fontSize: 28, fontWeight: 'bold', marginBottom: 16 },
  link: { fontSize: 16, color: '#61DAFB', marginTop: 12 },
})

The Link component handles navigation across all platforms. On the web, it renders an anchor tag with proper href attributes for SEO. On native platforms, it triggers stack-based navigation.

Project Structure and Layout Files

Expo Router uses _layout.tsx files to define navigation containers. Each directory can have its own layout, creating nested navigation hierarchies. The root layout wraps the entire app, while nested layouts control specific sections.

A typical project structure looks like this:

text
app/
  _layout.tsx          # Root layout (Stack or custom)
  index.tsx            # Home screen (/)
  (tabs)/              # Tab group (parentheses = route group)
    _layout.tsx        # Tab navigator
    home.tsx           # /home tab
    search.tsx         # /search tab
    profile.tsx        # /profile tab
  settings/
    _layout.tsx        # Settings stack layout
    index.tsx          # /settings
    notifications.tsx  # /settings/notifications
    privacy.tsx        # /settings/privacy

Route groups — directories wrapped in parentheses — organize files without affecting the URL. The (tabs) directory above creates a tab navigator, but the URLs remain /home, /search, and /profile rather than /tabs/home.

app/_layout.tsxtypescript
import { Stack } from 'expo-router'

export default function RootLayout() {
  return (
    <Stack
      screenOptions={{
        headerStyle: { backgroundColor: '#1a1a2e' },
        headerTintColor: '#ffffff',
        headerTitleStyle: { fontWeight: '600' },
      }}
    >
      <Stack.Screen name="index" options={{ title: 'Home' }} />
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      <Stack.Screen name="settings" options={{ title: 'Settings' }} />
    </Stack>
  )
}

The root layout also serves as the place to load fonts, initialize providers, and configure global settings — replacing the traditional App.tsx entry point.

Building Tab Navigation with Expo Router

Tab navigation requires a _layout.tsx file inside a route group. Expo Router v6 introduces NativeTabs for platform-specific tab experiences, but the standard Tabs component from Expo Router covers most use cases.

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

export default function TabLayout() {
  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: '#61DAFB',
        tabBarInactiveTintColor: '#888',
        tabBarStyle: {
          backgroundColor: '#1a1a2e',
          borderTopColor: '#2d2d44',
        },
      }}
    >
      <Tabs.Screen
        name="home"
        options={{
          title: 'Home',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="home" size={size} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="search"
        options={{
          title: 'Search',
          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>
  )
}

Each tab screen file exports a standard React component. The tab bar icon, label, and badge are configured through the options prop in the layout.

Dynamic Routes and Route Parameters

Dynamic segments use square brackets in the filename. A file named [id].tsx matches any single segment, while [...slug].tsx catches all remaining segments.

app/product/[id].tsxtypescript
import { View, Text, StyleSheet } from 'react-native'
import { useLocalSearchParams, Stack } from 'expo-router'

export default function ProductScreen() {
  // Extract the dynamic parameter from the URL
  const { id } = useLocalSearchParams<{ id: string }>()

  return (
    <View style={styles.container}>
      <Stack.Screen options={{ title: `Product ${id}` }} />
      <Text style={styles.heading}>Product Details</Text>
      <Text style={styles.id}>ID: {id}</Text>
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24 },
  heading: { fontSize: 24, fontWeight: 'bold', marginBottom: 8 },
  id: { fontSize: 16, color: '#888' },
})

Navigating to /product/42 renders this screen with id set to "42". The useLocalSearchParams hook provides typed access to all route parameters.

For catch-all routes, [...slug].tsx captures entire path segments:

app/docs/[...slug].tsxtypescript
import { useLocalSearchParams } from 'expo-router'

export default function DocsScreen() {
  // /docs/getting-started/installation → slug = ['getting-started', 'installation']
  const { slug } = useLocalSearchParams<{ slug: string[] }>()

  return <DocViewer path={slug.join('/')} />
}

Ready to ace your React Native interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Typed Routes for Compile-Time Safety

Expo Router generates route types automatically when typed routes is enabled. This catches broken links at compile time rather than at runtime.

Enable typed routes in app.json:

json
{
  "expo": {
    "experiments": {
      "typedRoutes": true
    }
  }
}

Once enabled, the href prop on Link and the argument to router.push() only accept valid route strings:

app/checkout.tsxtypescript
import { router } from 'expo-router'

function handleCheckout(cartId: string) {
  // TypeScript validates this route exists
  router.push(`/product/${cartId}`)

  // This would cause a compile error if /nonexistent doesn't exist
  // router.push('/nonexistent')
}

Typed routes pair well with useLocalSearchParams. The generated types ensure parameter names match between the route definition and the consuming component, preventing subtle bugs that surface only when navigating to a specific screen.

Modals in Expo Router are regular screens configured with presentation: 'modal' in the layout. This approach keeps the file-based convention intact — a modal is just another route.

app/_layout.tsxtypescript
import { Stack } from 'expo-router'

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="index" />
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      {/* Modal screen slides up from the bottom */}
      <Stack.Screen
        name="create-post"
        options={{
          presentation: 'modal',
          headerTitle: 'New Post',
        }}
      />
    </Stack>
  )
}
app/create-post.tsxtypescript
import { View, TextInput, Button, StyleSheet } from 'react-native'
import { router } from 'expo-router'
import { useState } from 'react'

export default function CreatePostModal() {
  const [title, setTitle] = useState('')

  const handleSubmit = () => {
    // Submit logic here
    router.back() // Dismiss the modal
  }

  return (
    <View style={styles.container}>
      <TextInput
        style={styles.input}
        placeholder="Post title"
        value={title}
        onChangeText={setTitle}
      />
      <Button title="Publish" onPress={handleSubmit} />
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24 },
  input: {
    borderWidth: 1,
    borderColor: '#333',
    borderRadius: 8,
    padding: 12,
    fontSize: 16,
    marginBottom: 16,
  },
})

Navigating to /create-post triggers the modal presentation. The router.back() call dismisses it, returning to the previous screen in the stack.

Programmatic Navigation and the Router API

Beyond the Link component, Expo Router provides an imperative API through the router object. This handles navigation triggered by business logic rather than user taps.

typescript
import { router } from 'expo-router'

// Push a new screen onto the stack
router.push('/profile/settings')

// Replace the current screen (no back button)
router.replace('/login')

// Go back to the previous screen
router.back()

// Navigate with parameters
router.push({
  pathname: '/product/[id]',
  params: { id: '42', source: 'recommendations' },
})

// Check if going back is possible
import { useRouter } from 'expo-router'

function BackButton() {
  const router = useRouter()
  return router.canGoBack() ? (
    <Button title="Back" onPress={() => router.back()} />
  ) : null
}

The router.push and router.replace distinction matters for authentication flows. After a successful login, router.replace('/dashboard') prevents the user from navigating back to the login screen.

Navigation vs. Redirect

router.replace() replaces the current entry in the navigation history. For authentication guards that redirect unauthenticated users, use <Redirect href="/login" /> inside the component render — it triggers during the render phase and works correctly with server-side rendering on web.

Middleware and Route Protection

Expo Router v6 introduces server middleware for route-level logic. The +middleware.ts file intercepts requests before they reach the route component, enabling authentication checks, redirects, and header manipulation.

app/+middleware.tstypescript
import { type MiddlewareRequest } from 'expo-router/server'

export function middleware(request: MiddlewareRequest) {
  const { pathname } = request.nextUrl

  // Protect dashboard routes
  const protectedPaths = ['/dashboard', '/settings', '/profile']
  const isProtected = protectedPaths.some(p => pathname.startsWith(p))

  if (isProtected) {
    const token = request.cookies.get('session')
    if (!token) {
      return Response.redirect(new URL('/login', request.url))
    }
  }

  return undefined // Continue to route
}

On native platforms where server middleware does not run, route protection relies on client-side guards. A common pattern wraps protected layouts with an authentication check:

app/(authenticated)/_layout.tsxtypescript
import { Redirect, Stack } from 'expo-router'
import { useAuth } from '@/hooks/useAuth'

export default function AuthenticatedLayout() {
  const { isLoggedIn, isLoading } = useAuth()

  if (isLoading) return null
  if (!isLoggedIn) return <Redirect href="/login" />

  return <Stack />
}
Platform Awareness

Server middleware runs only on web with server-side rendering. For native apps, always implement client-side route guards in layout components. Combining both approaches ensures consistent protection across all platforms.

Conclusion

  • Expo Router v6 replaces manual navigation configuration with file-based conventions, mapping each file in the app directory to a route automatically
  • Layouts defined through _layout.tsx files create navigation hierarchies — stacks, tabs, and drawers — without centralized config
  • Dynamic routes with [param].tsx and catch-all routes with [...slug].tsx handle parameterized navigation with full TypeScript support
  • Typed routes catch broken navigation links at compile time when enabled in app.json
  • Modal screens, programmatic navigation via router.push/replace/back, and server middleware round out the routing toolkit
  • Route groups using parenthesized directories organize code without affecting URLs, keeping the file tree clean as the app grows
  • Client-side route guards in layouts handle authentication on native, while server middleware covers web — use both for cross-platform apps

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Tags

#react-native
#expo
#expo-router
#navigation
#mobile-development
#tutorial

Share

Related articles