Why Expo Router Changes Everything About React Native Navigation
If you've built a React Native app before, you know the drill. Install React Navigation, set up a navigation container, define stacks and tabs imperatively, wire up deep links by hand, and then cross your fingers that type safety doesn't fall apart as your app grows. It's a lot.
Expo Router takes a completely different approach.
It's a file-based routing system for React Native (and web) apps, built on top of React Navigation but with a radically different developer experience. Instead of configuring routes in code, you create files in a directory. Each file becomes a route. Every screen is automatically deep-linkable. And as of Expo SDK 55 and React Native 0.83, it's the recommended way to handle navigation in new Expo projects.
This guide walks through everything — from the basics of file-based routing to more advanced patterns like authentication flows, modal presentation, typed routes, and dynamic segments. Whether you're starting fresh or migrating from React Navigation, you'll come away with a solid understanding of how Expo Router works in practice.
Getting Started: Project Setup
The quickest way to spin up an Expo Router project is with the Expo CLI. As of SDK 55, the default template ships with Expo Router and a tabs layout already configured:
npx create-expo-app@latest my-app
cd my-app
npx expo start
That's literally it. No extra packages, no navigation container to set up. You get a working tab-based navigation structure right out of the box.
If you're retrofitting Expo Router into an existing project, install the required dependencies:
npx expo install expo-router react-native-safe-area-context react-native-screens expo-linking expo-constants expo-status-bar
Then update your app.json to point to the Expo Router entry point:
{
"expo": {
"scheme": "myapp",
"web": {
"bundler": "metro",
"output": "static"
},
"plugins": ["expo-router"]
}
}
Understanding File-Based Routing: The Core Concept
The fundamental idea is dead simple: your file system is your route configuration. Every file inside the app/ directory automatically becomes a navigable route.
Basic File-to-Route Mapping
Here's how files map to routes:
app/
├── _layout.tsx → Root layout (wraps all routes)
├── index.tsx → / (home screen)
├── about.tsx → /about
├── settings.tsx → /settings
└── profile/
├── _layout.tsx → Layout for /profile/* routes
├── index.tsx → /profile
└── edit.tsx → /profile/edit
The index.tsx file in any directory matches that directory's path. The _layout.tsx file defines how child routes are arranged — stack, tabs, drawer, or any custom layout you want.
The Root Layout
Every Expo Router app needs a root layout at app/_layout.tsx. This is where you set up providers, configure the overall navigation structure, and handle app-wide initialization:
// app/_layout.tsx
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
export default function RootLayout() {
return (
<>
<StatusBar style="auto" />
<Stack>
<Stack.Screen name="index" options={{ title: "Home" }} />
<Stack.Screen name="about" options={{ title: "About" }} />
<Stack.Screen name="settings" options={{ title: "Settings" }} />
</Stack>
</>
);
}
If you had initialization logic living in App.tsx before, it should move here. The root layout is the first component that renders, making it the natural home for font loading, authentication checks, and global context providers.
Navigation Patterns: Tabs, Stacks, and Drawers
Tab Navigation
Tabs are probably the most common pattern in mobile apps. With Expo Router, you create a directory with a _layout.tsx that uses the Tabs component:
app/
├── _layout.tsx
└── (tabs)/
├── _layout.tsx → Tab navigator layout
├── index.tsx → First tab (Home)
├── search.tsx → Second tab (Search)
└── profile.tsx → Third tab (Profile)
The tab layout file configures the tab bar:
// 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" 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>
);
}
Notice the (tabs) directory name with parentheses? That's a route group. The parentheses tell Expo Router this directory is for organizational purposes only and shouldn't appear in the URL path. So routes inside (tabs)/ are accessible at /, /search, and /profile — not /tabs/search.
Stack Navigation Inside Tabs
In real-world apps, each tab usually needs its own stack of screens. You handle this by nesting directories inside each tab:
app/
└── (tabs)/
├── _layout.tsx
├── index.tsx → Home tab (/)
└── profile/
├── _layout.tsx → Stack layout for profile
├── index.tsx → /profile
├── edit.tsx → /profile/edit
└── settings.tsx → /profile/settings
The profile layout defines a stack navigator for the screens within that tab:
// app/(tabs)/profile/_layout.tsx
import { Stack } from "expo-router";
export default function ProfileLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: "Profile" }} />
<Stack.Screen name="edit" options={{ title: "Edit Profile" }} />
<Stack.Screen name="settings" options={{ title: "Settings" }} />
</Stack>
);
}
Drawer Navigation
For drawer-based layouts, install the drawer dependency and use the Drawer component:
npx expo install @react-navigation/drawer react-native-gesture-handler react-native-reanimated
// app/_layout.tsx
import { Drawer } from "expo-router/drawer";
import { GestureHandlerRootView } from "react-native-gesture-handler";
export default function DrawerLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Drawer>
<Drawer.Screen name="index" options={{ title: "Home" }} />
<Drawer.Screen name="settings" options={{ title: "Settings" }} />
</Drawer>
</GestureHandlerRootView>
);
}
Dynamic Routes and URL Parameters
Dynamic routes let you match URL segments that vary — think user IDs, product slugs, or article identifiers. You create them by wrapping the filename in square brackets.
Single Dynamic Segment
app/
└── user/
└── [id].tsx → /user/123, /user/abc, etc.
// app/user/[id].tsx
import { useLocalSearchParams } from "expo-router";
import { Text, View } from "react-native";
export default function UserScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Text>User ID: {id}</Text>
</View>
);
}
Multiple Dynamic Segments
You can have multiple dynamic segments in a single path:
app/
└── [category]/
└── [productId].tsx → /electronics/iphone-15, /books/react-native-guide
// app/[category]/[productId].tsx
import { useLocalSearchParams } from "expo-router";
export default function ProductScreen() {
const { category, productId } = useLocalSearchParams<{
category: string;
productId: string;
}>();
return (
<View>
<Text>Category: {category}</Text>
<Text>Product: {productId}</Text>
</View>
);
}
Catch-All Routes
Catch-all routes match any number of path segments. Use the [...slug] syntax:
app/
└── docs/
└── [...slug].tsx → /docs/a, /docs/a/b, /docs/a/b/c
// app/docs/[...slug].tsx
import { useLocalSearchParams } from "expo-router";
export default function DocsScreen() {
const { slug } = useLocalSearchParams<{ slug: string[] }>();
// slug is an array: ["a", "b", "c"] for /docs/a/b/c
return (
<View>
<Text>Path: {slug?.join("/")}</Text>
</View>
);
}
useLocalSearchParams vs. useGlobalSearchParams
Expo Router gives you two hooks for accessing URL parameters, and honestly, picking the right one matters more than you'd expect:
- useLocalSearchParams: Returns parameters for the current route. It only updates when the route is focused. This is what you want 90% of the time, especially inside stacks where previous screens shouldn't re-render when deeper screens change.
- useGlobalSearchParams: Returns the current globally selected route's parameters. Updates on every navigation event. Use this sparingly — it triggers re-renders across your whole app.
Rule of thumb? Reach for useLocalSearchParams by default. Only use useGlobalSearchParams when you genuinely need a component to react to navigation changes happening elsewhere.
Navigation: Links, Programmatic Navigation, and the Router API
The Link Component
The Link component is the primary way to navigate between screens declaratively:
import { Link } from "expo-router";
export default function HomeScreen() {
return (
<View>
{/* Simple navigation */}
<Link href="/about">Go to About</Link>
{/* Dynamic route */}
<Link href="/user/42">View User 42</Link>
{/* With query parameters */}
<Link href={{ pathname: "/search", params: { q: "react native" } }}>
Search
</Link>
{/* Replace instead of push */}
<Link href="/login" replace>Go to Login</Link>
{/* Using asChild for custom components */}
<Link href="/profile" asChild>
<Pressable>
<Text>My Profile</Text>
</Pressable>
</Link>
</View>
);
}
Programmatic Navigation with useRouter
For navigation triggered by events — button presses, form submissions, API responses — use the useRouter hook:
import { useRouter } from "expo-router";
export default function LoginScreen() {
const router = useRouter();
const handleLogin = async () => {
const success = await authenticate();
if (success) {
// Push a new screen onto the stack
router.push("/dashboard");
// Or replace the current screen
router.replace("/dashboard");
// Navigate back
router.back();
// Navigate with parameters
router.push({
pathname: "/user/[id]",
params: { id: "42" },
});
// Dismiss the current modal
router.dismiss();
// Dismiss all modals and return to the root
router.dismissAll();
}
};
return <Button title="Log In" onPress={handleLogin} />;
}
Key Navigation Methods
Here's a quick reference for the router API:
- router.push(href): Adds a new screen to the navigation stack
- router.replace(href): Replaces the current screen without adding to the stack
- router.back(): Goes back to the previous screen
- router.dismiss(): Dismisses the current modal screen
- router.dismissAll(): Dismisses all modal screens
- router.canGoBack(): Returns whether a back navigation is possible
- router.canDismiss(): Returns whether a modal dismiss is possible
Route Groups and Organizing Your App
Route groups are one of Expo Router's most powerful organizational features. And once you start using them, you'll wonder how you ever lived without them.
By wrapping a directory name in parentheses, you create a group that doesn't affect the URL structure but lets you organize your code logically.
Common Use Cases for Route Groups
app/
├── _layout.tsx
├── (auth)/
│ ├── _layout.tsx → Layout for auth screens
│ ├── sign-in.tsx → /sign-in
│ └── sign-up.tsx → /sign-up
├── (app)/
│ ├── _layout.tsx → Layout for authenticated app
│ └── (tabs)/
│ ├── _layout.tsx → Tab layout
│ ├── home.tsx → /home
│ ├── feed.tsx → /feed
│ └── profile.tsx → /profile
└── (modals)/
├── _layout.tsx → Layout for modals
└── settings.tsx → /settings
This structure cleanly separates auth screens from the main app, each with their own layout and navigation behavior, while keeping the URLs clean and flat. Pretty elegant, if you ask me.
Authentication and Protected Routes
Handling auth is one of the most common (and honestly, most annoying) navigation challenges in mobile apps. Expo Router gives you two solid approaches: redirect-based authentication and the newer Stack.Protected API.
Redirect-Based Authentication
The classic pattern uses a layout that checks auth state and redirects accordingly:
// context/auth.tsx
import { createContext, useContext, useState, useEffect } from "react";
import * as SecureStore from "expo-secure-store";
interface AuthContextType {
user: User | null;
isLoading: boolean;
signIn: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType>(null!);
export function useAuth() {
return useContext(AuthContext);
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadUser();
}, []);
async function loadUser() {
const token = await SecureStore.getItemAsync("authToken");
if (token) {
const userData = await fetchUser(token);
setUser(userData);
}
setIsLoading(false);
}
async function signIn(email: string, password: string) {
const { token, user } = await loginAPI(email, password);
await SecureStore.setItemAsync("authToken", token);
setUser(user);
}
async function signOut() {
await SecureStore.deleteItemAsync("authToken");
setUser(null);
}
return (
<AuthContext.Provider value={{ user, isLoading, signIn, signOut }}>
{children}
</AuthContext.Provider>
);
}
// app/_layout.tsx
import { Slot } from "expo-router";
import { AuthProvider } from "../context/auth";
export default function RootLayout() {
return (
<AuthProvider>
<Slot />
</AuthProvider>
);
}
// app/(app)/_layout.tsx
import { Redirect, Stack } from "expo-router";
import { useAuth } from "../../context/auth";
import { ActivityIndicator, View } from "react-native";
export default function AppLayout() {
const { user, isLoading } = useAuth();
if (isLoading) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator size="large" />
</View>
);
}
if (!user) {
return <Redirect href="/sign-in" />;
}
return <Stack />;
}
Stack.Protected: The Modern Approach
Expo Router also introduced Stack.Protected for a more declarative way to handle route protection. Instead of manual redirects, you mark screens as protected directly in your layout:
// app/_layout.tsx
import { Stack } from "expo-router";
import { useAuth } from "../context/auth";
export default function RootLayout() {
const { user } = useAuth();
return (
<Stack>
<Stack.Screen name="sign-in" />
<Stack.Screen name="sign-up" />
<Stack.Protected guard={!!user}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="settings" />
</Stack.Protected>
</Stack>
);
}
When guard is false, any attempt to navigate to protected screens (including deep links) automatically redirects to the first unprotected screen. It works with tabs and drawer navigators too, which makes it a great pattern for role-based access control.
Role-Based Access Control
You can nest Stack.Protected blocks for more granular permission control:
export default function RootLayout() {
const { user } = useAuth();
return (
<Stack>
<Stack.Screen name="sign-in" />
<Stack.Protected guard={!!user}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="profile" />
</Stack.Protected>
<Stack.Protected guard={user?.role === "admin"}>
<Stack.Screen name="admin-panel" />
<Stack.Screen name="user-management" />
</Stack.Protected>
</Stack>
);
}
Modal Presentation
Modals are screens that slide over the current content — commonly used for forms, confirmations, or detail views. Expo Router supports modals through the presentation option on stack screens.
Setting Up Modals
The recommended pattern is to place modal routes outside your tab group so they overlay the entire screen:
app/
├── _layout.tsx → Root stack with modal config
├── (tabs)/
│ ├── _layout.tsx
│ ├── index.tsx
│ └── feed.tsx
└── create-post.tsx → Modal screen
// app/_layout.tsx
import { Stack } from "expo-router";
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="create-post"
options={{
presentation: "modal",
title: "New Post",
}}
/>
</Stack>
);
}
Sheet Presentation with Detents
For iOS-style sheet presentations with customizable snap points (this is one of my favorite features), use the formSheet presentation with detents:
<Stack.Screen
name="share"
options={{
presentation: "formSheet",
sheetAllowedDetents: [0.25, 0.5, 1],
sheetGrabberVisible: true,
sheetCornerRadius: 20,
}}
/>
Detents define the heights where the sheet can rest. A value of 0.25 means 25% of the screen, 0.5 is half, and 1 is full screen. Users can swipe between these positions, creating a natural, native-feeling interaction.
Platform Considerations
One thing to watch out for: presentation: "modal" behaves differently across platforms. On iOS, you get the standard slide-up animation. On Android, it typically renders as a regular screen push (depending on your React Navigation version). If you need consistent cross-platform modal behavior, you might want to reach for a library like react-native-bottom-sheet for sheet-style presentations.
Typed Routes: End-to-End Type Safety
So, here's where things get really cool. One of Expo Router's standout features is automatic TypeScript route generation. When you run npx expo start, the CLI generates a typed routes file that makes every Link component and navigation call type-safe.
Enabling Typed Routes
Enable typed routes in your app.json:
{
"expo": {
"experiments": {
"typedRoutes": true
}
}
}
After starting the dev server, Expo CLI generates type definitions reflecting your file structure. Now your IDE autocompletes route paths and catches typos at compile time:
// TypeScript will catch this — "/abuot" doesn't exist
<Link href="/abuot">About</Link> // Type error
// This is fine
<Link href="/about">About</Link> // Compiles
// Dynamic routes are typed too
router.push({
pathname: "/user/[id]",
params: { id: "42" }, // params type is inferred
});
Typed routes are a massive productivity boost in larger apps. Instead of discovering broken navigation paths at runtime (usually during a demo, of course), your editor flags them immediately.
Error Handling and Not-Found Routes
Production apps need graceful error handling. Expo Router has built-in support for both component-level errors and unmatched routes.
Route-Level Error Boundaries
Export an ErrorBoundary component from any route file to catch rendering errors on that screen:
// app/feed.tsx
import { ErrorBoundary as ExpoErrorBoundary } from "expo-router";
export function ErrorBoundary({ error, retry }: { error: Error; retry: () => void }) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Text>Something went wrong</Text>
<Text style={{ color: "gray" }}>{error.message}</Text>
<Button title="Try Again" onPress={retry} />
</View>
);
}
export default function FeedScreen() {
// If this component throws, ErrorBoundary catches it
return <FeedContent />;
}
Error boundaries work per-route. If a screen doesn't export an ErrorBoundary, errors bubble up to the nearest parent layout that does. This gives you fine-grained control over error recovery without a lot of boilerplate.
Handling 404 / Not Found
Create a +not-found.tsx file at the root of your app directory to catch unmatched routes:
// app/+not-found.tsx
import { Link, Stack } from "expo-router";
import { View, Text, StyleSheet } from "react-native";
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: "Page Not Found" }} />
<View style={styles.container}>
<Text style={styles.title}>This page doesn't exist</Text>
<Link href="/" style={styles.link}>
Go back home
</Link>
</View>
</>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: "center", alignItems: "center" },
title: { fontSize: 20, fontWeight: "bold" },
link: { marginTop: 16, color: "#007AFF" },
});
Deep Linking: Automatic and Universal
This is where Expo Router really shines compared to a traditional React Navigation setup. Every route you create is automatically a deep link. No separate deep link configuration. No manual linking config to maintain. It just works.
How It Works
Because routes are defined by your file system, Expo Router knows exactly which screen matches any given URL. When your app receives a deep link like myapp://user/42, it navigates to app/user/[id].tsx with id set to "42".
For native deep links, configure your app's scheme in app.json:
{
"expo": {
"scheme": "myapp",
"ios": {
"associatedDomains": ["applinks:example.com"]
},
"android": {
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [{ "scheme": "https", "host": "example.com" }],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}
With this setup, both myapp://user/42 and https://example.com/user/42 open the same screen. Universal links on iOS and App Links on Android work seamlessly because the route structure is identical on web and native.
Deep Linking and Protected Routes
Here's something worth highlighting: deep links respect your authentication guards. If an unauthenticated user taps a deep link to a protected screen, they get redirected to sign-in first. Once they authenticate, they can navigate to the originally intended destination.
This eliminates an entire class of bugs that plague apps with manual deep link handling. I've seen teams spend weeks debugging edge cases around unauthenticated deep links — Expo Router handles it automatically.
Performance Optimization Tips
As your app grows, navigation performance matters. Here are some practical tips for keeping things snappy.
Lazy Loading with Async Routes
In development, Expo Router lazily loads routes by default — each screen's JavaScript is only loaded when it's first visited. In production, all routes are bundled together for reliability. On web, you can enable async routes for production as well:
// app.json
{
"expo": {
"web": {
"bundler": "metro",
"output": "static"
},
"experiments": {
"typedRoutes": true
}
}
}
Minimize Re-Renders with useLocalSearchParams
As I mentioned earlier, prefer useLocalSearchParams over useGlobalSearchParams. The global variant triggers re-renders on every navigation event, which can cause cascading updates across your entire app. The local variant only updates when the specific route is focused — much better for performance.
Optimize Tab Screens
Tab screens stay mounted in memory after their first visit. For data-heavy tabs, use useFocusEffect from expo-router to refresh data only when the tab becomes visible, rather than fetching on mount:
import { useFocusEffect } from "expo-router";
import { useCallback } from "react";
export default function FeedScreen() {
useFocusEffect(
useCallback(() => {
// Fetch or refresh data when tab is focused
refreshFeed();
}, [])
);
return <FeedList />;
}
Migrating from React Navigation
If you've got an existing app using React Navigation, here's the good news: migrating to Expo Router is pretty straightforward because it's built on top of React Navigation. Your existing knowledge carries over directly.
Migration Strategy
- Create the app directory: Start with your root layout mirroring your current
NavigationContainersetup - Move screens one at a time: Convert each screen component to a file in the app directory, starting with the most-used routes
- Replace imperative configuration: Convert
Stack.NavigatorandTab.Navigatorsetups into_layout.tsxfiles - Update navigation calls: Replace
navigation.navigate("ScreenName")withrouter.push("/route-path") - Remove linking configuration: Delete your manual deep link config — Expo Router handles it for free
Before and After
// BEFORE: React Navigation
const Stack = createNativeStackNavigator();
function AppNavigator() {
return (
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Profile" component={ProfileScreen} />
<Stack.Screen name="Settings" component={SettingsScreen} />
</Stack.Navigator>
</NavigationContainer>
);
}
// Navigate:
navigation.navigate("Profile", { userId: "42" });
// AFTER: Expo Router
// app/_layout.tsx
export default function Layout() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: "Home" }} />
<Stack.Screen name="profile/[userId]" />
<Stack.Screen name="settings" />
</Stack>
);
}
// Navigate:
router.push("/profile/42");
Less code, automatic deep linking, and type safety for free. That's a win in my book.
Real-World Project Structure
Let me put it all together. Here's a complete project structure for a production-quality app using Expo Router, combining all the patterns we've covered:
app/
├── _layout.tsx → Root layout (providers, auth check)
├── +not-found.tsx → 404 handler
├── (auth)/
│ ├── _layout.tsx → Stack for auth screens
│ ├── sign-in.tsx → /sign-in
│ ├── sign-up.tsx → /sign-up
│ └── forgot-password.tsx → /forgot-password
├── (app)/
│ ├── _layout.tsx → Protected layout (checks auth)
│ └── (tabs)/
│ ├── _layout.tsx → Tab navigator
│ ├── index.tsx → Home tab
│ ├── search.tsx → Search tab
│ ├── notifications.tsx → Notifications tab
│ └── profile/
│ ├── _layout.tsx → Profile stack
│ ├── index.tsx → /profile
│ └── edit.tsx → /profile/edit
├── post/
│ └── [id].tsx → /post/:id (detail view)
├── user/
│ └── [username].tsx → /user/:username
└── create-post.tsx → Modal for creating posts
src/
├── components/ → Shared UI components
├── context/ → Auth, theme, and other providers
├── hooks/ → Custom hooks
├── services/ → API client and services
└── utils/ → Helper functions
Notice the separation between the app/ directory (routes and layouts) and the src/ directory (application logic). It keeps things clean. And worth noting: Expo SDK 55's default template now supports src/app as an alternative location, so you can keep everything inside src/ if that's more your style.
Wrapping Up
Expo Router represents a real shift in how we think about navigation in React Native. By making your file system the source of truth for routing, it eliminates a ton of configuration, makes deep linking automatic, and brings type safety to every navigation action.
Here are the key takeaways:
- Files are routes. Drop a file in the
app/directory and it's a navigable screen. - Layouts control structure. Use
_layout.tsxfiles with Stack, Tabs, or Drawer to define how child routes are presented. - Route groups organize without affecting URLs. Parenthesized directories like
(auth)and(app)keep your code tidy while maintaining clean URL paths. - Authentication is declarative. Use
Stack.Protectedor redirect-based patterns to guard routes with minimal boilerplate. - Deep linking is free. Every route is automatically a deep link — zero configuration required.
- Type safety is built in. Enable typed routes and catch navigation bugs at compile time, not runtime.
If you're starting a new React Native project in 2026, Expo Router should be your default choice for navigation. And if you're maintaining an existing app with React Navigation, the migration path is smooth enough that it's worth putting on the roadmap. The developer experience improvement alone — not to mention automatic deep linking and type safety — makes it a clear win.