React Navigation is the routing standard for React Native. It works. It also has a configuration problem. As your app grows, the imperative createStackNavigator and createBottomTabNavigator calls become a tangled mess of strings and parameters. Deep linking is bolted on. Type safety is rare. Refactoring is hard.
Expo Router brings the Next.js model to React Native. Routes are files. Directories are nested layouts. Deep links come for free. Typed routes are an opt-in experiment away. After shipping two client apps on it, I am sold. Here is how to set it up and what to watch for.
The file structure
Every file in app/ is a route. The directory structure is the route hierarchy.
app/
_layout.tsx # wraps every screen
index.tsx # /
login.tsx # /login
(tabs)/
_layout.tsx # tab bar
index.tsx # /
profile.tsx # /profile
post/
[id].tsx # /post/:id
index.tsx # /post
That is the whole routing config. No string-based route names. Add a file, get a route. Delete a file, the route is gone.
Layouts
Each directory can have a _layout.tsx file that wraps every screen inside it. This is where you put tab bars, stack headers, and auth gates.
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router'
export default function TabLayout() {
return (
<Tabs screenOptions={{ headerShown: true }}>
<Tabs.Screen
name="index"
options={{ title: 'Home' }}
/>
<Tabs.Screen
name="profile"
options={{ title: 'Profile' }}
/>
</Tabs>
)
}The (tabs) directory name with parentheses is a route group. It groups screens under a shared layout without affecting the URL. The routes inside are still / and /profile, not /tabs/ and /tabs/profile.
Dynamic routes
Bracket notation creates dynamic routes:
// app/post/[id].tsx
import { useLocalSearchParams } from 'expo-router'
export default function PostScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
// ... fetch post by id
}/post/123 renders this screen with id === '123'. app/post/[author]/[id].tsx handles /post/jane/123 with both params.
For catch-all routes:
// app/blog/[...slug].tsx
const { slug } = useLocalSearchParams<{ slug: string[] }>()
// slug is ['2026', '08', 'my-post'] for /blog/2026/08/my-postTyped routes
Turn on typed routes:
// app.json
{
"expo": {
"experiments": {
"typedRoutes": true
}
}
}Expo Router generates types for every route in your app. Navigation calls get autocomplete and compile-time safety:
import { router } from 'expo-router'
router.push('/post/123') // valid
router.push('/post/123/comments') // valid if that route exists
router.push('/post/123/wrong') // type error
router.push('/pots/123') // type error, typo caughtThis is huge for refactoring. Rename a route, every link to it breaks at compile time. Add a new route, every screen in the app can navigate to it without writing any config.
Navigation patterns
Three main ways to navigate:
import { router, Link } from 'expo-router'
// Imperative
router.push('/post/123')
router.replace('/login')
router.back()
// Declarative — for links in JSX
<Link href="/post/123">Read more</Link>
// With params
router.push({
pathname: '/post/[id]',
params: { id: '123' },
})The Link component renders a pressable that navigates. It supports asChild to forward props to a child component, which is how you make custom buttons navigate.
Deep linking for free
Expo Router handles deep links by default. Configure the scheme in app.json:
{
"expo": {
"scheme": "myapp"
}
}Universal Links and App Links work automatically if you set up the association files (see my article on React Native deep linking). The URL /post/123 resolves to app/post/[id].tsx with id === '123'. No manual linking config.
This is the single biggest reason to use Expo Router. Deep linking that "just works" saves weeks of integration work.
Authentication flow
The pattern I use for auth-gated routes:
// app/_layout.tsx
import { Stack, useRouter, useSegments } from 'expo-router'
import { useEffect } from 'react'
function useAuthGate() {
const { session, isLoading } = useSession()
const segments = useSegments()
const router = useRouter()
useEffect(() => {
if (isLoading) return
const inAuthGroup = segments[0] === '(auth)'
if (!session && !inAuthGroup) {
router.replace('/(auth)/login')
} else if (session && inAuthGroup) {
router.replace('/')
}
}, [session, isLoading, segments])
}
export default function RootLayout() {
useAuthGate()
return <Stack screenOptions={{ headerShown: false }} />
}The gate runs on every navigation. If the user is signed out and tries to access a protected route, redirect to login. If they are signed in and on a login screen, redirect home.
Migrating from React Navigation
If you have an existing React Navigation app, you can migrate incrementally:
- Keep your existing stack of screens.
- Add Expo Router alongside React Navigation using the interop libraries.
- Move routes to the file system one at a time.
Or just keep React Navigation. It is not going anywhere. Expo Router is the better default for new projects, but React Navigation is still actively maintained and works fine.
Pitfalls
- Bundle size — Expo Router adds some KB to your app. Small price for the features, but worth knowing.
- Build complexity — slightly more setup than vanilla React Navigation. The Expo tooling handles most of it.
- Learning curve — file-based routing has its own conventions. If your team only knows React Navigation, expect a week of adjustment.
- Edge cases — modals, presentations, and platform-specific screens have their own patterns. Read the docs once before you start.
When to use Expo Router
Use it for new projects. Use it for projects that need deep linking. Use it for projects where typed navigation matters. Use it for projects where the team has Next.js experience and benefits from the mental model transfer.
Stick with React Navigation if you have an existing app that works and you have no specific reason to migrate.
Expo Router is what React Navigation should have been for file-based routing. Use it for new apps, save yourself the deep linking integration, and enjoy typed navigation.
Want help setting up Expo Router?
I build React Native apps with Expo Router, typed routes, and deep linking baked in from day one. Let's talk.
Frequently Asked Questions
What is Expo Router?
Expo Router is a file-based routing system for React Native, built on top of React Navigation. You define routes as files in the app/ directory, similar to Next.js. It handles nested layouts, dynamic routes, deep links, and typed navigation.
Should I use Expo Router or React Navigation?
For new projects, use Expo Router. The file-based structure scales better, deep linking is automatic, and typed routes catch navigation bugs at compile time. For existing React Navigation apps, migrating is optional — the libraries interoperate.
How do dynamic routes work in Expo Router?
Name a file with brackets to create a dynamic route. app/post/[id].tsx matches /post/123 and exposes id as a parameter via the useLocalSearchParams hook. Splat routes like app/blog/[...slug].tsx match multiple segments.
Does Expo Router support typed routes?
Yes. Set experiments.typedRoutes to true in app.json. Expo Router generates types for every route, and the router.push and router.navigate methods accept only valid paths. This catches typos and dead links at compile time.