Expo Router: File-Based Navigation That Crosses Every Screen
React Native gave the world a way to write one codebase that runs natively on iOS and Android, but it famously left one big decision wide open: how do you move between screens? For years the answer was to hand-wire React Navigation with a central config object, manually register every screen, and sprinkle stringy route names like navigation.navigate('ProfileScreen') throughout your app. Refactoring a single screen could mean touching the navigator config and a dozen call sites. Expo Router replaces all of that with a single, familiar idea: the file system is your route table.
If you have ever used Next.js or Remix on the web, you already understand Expo Router. You create a file in an app/ directory, and you get a route. No registration, no config object, no stringy screen names. But unlike those web frameworks, Expo Router targets native first: it compiles down to real native stacks, tabs, and gestures via React Navigation and react-native-screens, while web rendering comes along as a bonus. The result is genuinely universal navigation, one mental model that works on a phone, a tablet, and a browser tab.
Why It Earns a Place in Your Stack
Expo Router is not just sugar over React Navigation. The convention-over-configuration approach unlocks a whole category of features that used to be painful to wire up by hand.
- Deep linking by default. Every screen automatically has a URL on every platform. Shareable links and OS deep links work with zero extra configuration, something that used to be a feature only large teams bothered to get right.
- Universal web support. The same routes render on the web, with static rendering (SSG) for SEO and experimental server-side rendering in newer versions.
- Built on React Navigation. You get native stack, tab, and drawer navigators, swipe gestures, and screen-level performance optimizations, all configured declaratively through files.
- Typed routes. Static TypeScript checking and autocomplete for your
hrefvalues, so a typo in a path becomes a compile error instead of a crash. - API routes. Backend endpoints can live right next to your screens in
app/api/, turning your app project into a tiny full-stack project. - Native UX, declared. Newer releases bring native tab bars, iOS toolbars with liquid-glass effects, Material 3 dynamic colors, and Apple-style zoom transitions, all expressed as React components.
A quick note on version numbers, because they confuse newcomers. Expo Router used to ship as 3.x, 4.x, 5.x, 6.x, and then Expo aligned the major version with the Expo SDK. So 55.x ships with SDK 55, 56.x (the current stable) ships with SDK 56, and 57.x is in canary. If you see "v6" in an old tutorial and "v56" in the registry, they are the same project, just before and after the renumbering.
Getting It Into Your Project
The fastest path is to scaffold a fresh app with the router template, which wires up the Expo toolchain and a starter app/ directory for you:
npx create-expo-app@latest my-app
To add Expo Router to an existing Expo project, install it directly. Note that it expects the Expo modules and CLI to be present, since packages like expo-linking and expo-constants are peer dependencies.
npm install expo-router
yarn add expo-router
After installing, point your app entry at the router and make sure your app/ directory exists. From there, creating files is creating routes.
Turning Folders Into Screens
Every route lives in the app/ directory (or src/app/ if you keep a src folder). The shape of that folder tree is the shape of your navigation. A handful of naming conventions cover almost everything.
The root app/_layout.tsx replaces the old App.jsx. It declares the top-level navigator and is where you load fonts, set up providers, and control the splash screen.
// app/_layout.tsx
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: 'Home' }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
);
}
A _layout.tsx file wraps everything in its directory and declares the navigator for that level, whether that is a Stack, Tabs, or Drawer. Meanwhile app/index.tsx matches /, and any folder's index.tsx is the default route for that folder.
Dynamic segments use square brackets. A file at app/user/[id].tsx matches /user/bacon, and you read the captured value with a hook:
// app/user/[id].tsx
import { useLocalSearchParams } from 'expo-router';
import { Text } from 'react-native';
export default function User() {
const { id } = useLocalSearchParams<{ id: string }>();
return <Text>User: {id}</Text>;
}
Finally, parenthesized folders are route groups. They organize files and attach a layout without adding a URL segment. So app/(tabs)/home.tsx is simply /home, but it inherits the tab bar declared in app/(tabs)/_layout.tsx. Here is how a small app might come together:
src/app/
├── _layout.tsx # root Stack, providers, fonts
├── index.tsx # "/"
├── (tabs)/
│ ├── _layout.tsx # Tabs navigator
│ ├── index.tsx # "/" (first tab)
│ └── settings.tsx # "/settings"
├── user/
│ └── [id].tsx # "/user/:id"
└── +not-found.tsx # 404
Moving Between Screens
Navigation in Expo Router feels like the web. The primary primitive is Link, which renders text by default but can wrap any pressable component with asChild:
import { Link } from 'expo-router';
import { Pressable, Text } from 'react-native';
export function Nav() {
return (
<>
<Link href="/about">About</Link>
<Link href="/other" asChild>
<Pressable>
<Text>Home</Text>
</Pressable>
</Link>
<Link href={{ pathname: '/user/[id]', params: { id: 'bacon' } }}>
View user
</Link>
</>
);
}
When you need to navigate imperatively, for example after a form submits, reach for useRouter:
import { useRouter } from 'expo-router';
export function useAuthActions() {
const router = useRouter();
return {
goToAbout: () => router.push('/about'), // push a new screen
visit: () => router.navigate('/about'), // push or unwind to existing
signOut: () => router.replace('/login'), // replace current
goBack: () => router.back(), // pop
};
}
Reading the current location is just as ergonomic. Use useLocalSearchParams() for this screen's params, useGlobalSearchParams() for params anywhere in the URL, and useSegments() to inspect the active route segments, which is the trick that powers auth-gating in a layout.
Locking Down Routes With Types
Typed routes turn your navigation into something the TypeScript compiler can verify. Enable them in app.json under experiments.typedRoutes, and Expo generates a git-ignored expo-env.d.ts file that teaches your editor about every valid path.
{
"expo": {
"experiments": {
"typedRoutes": true
}
}
}
Once that flag is on, the rules get stricter in ways that pay off. Relative paths are out, so you use absolute paths like /about or compute segments with useSegments(). Dynamic routes must use the object form, because a bare "/user/[id]" string is now a type error:
// Type-checked: the compiler knows /user/[id] needs an `id`
<Link href={{ pathname: '/user/[id]', params: { id: user.id } }}>
{user.name}
</Link>
Query parameters still need a manual generic so the hook knows their shape, but in exchange you get autocomplete on every link and a red squiggle the instant you rename a screen and forget to update a navigation call.
Standing on a Real Native Tab Bar
One of the freshest additions, landing across SDK 54 through 56, is native tabs. Instead of a JavaScript reimplementation of a tab bar, expo-router/unstable-native-tabs renders the platform's genuine tab component: UITabBar on iOS and the Material tab bar on Android, complete with Material 3 dynamic colors.
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings">
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}
Notice that each trigger declares both an iOS SF Symbol (sf) and an Android Material icon (md), so a single declaration looks right on both platforms. Unlike the JavaScript tabs, native triggers must be added explicitly; they are not auto-populated from your files. Because it is still an alpha feature, the import path carries the unstable- prefix and availability varies by SDK, so check what version you are on before leaning on the newest props.
Colocating a Backend With API Routes
Expo Router can also serve backend endpoints that live right beside your screens. A file like app/api/hello+api.ts that exports GET or POST functions becomes a server endpoint:
// app/api/hello+api.ts
export function GET(request: Request) {
return Response.json({ hello: 'world' });
}
These use standard web Request and Response objects, so the code feels at home to anyone who has touched a modern web framework. The one catch worth remembering: API routes only run when you build with output: "server" and deploy to a host like EAS Hosting. They are not available in a purely static export or a plain native build, so treat them as a feature of your deployed app rather than your local bundle.
A Few Honest Caveats
Expo Router is opinionated, and a couple of those opinions are worth knowing up front. It is part of the Expo ecosystem, so while it can run in a bare React Native project, it really expects the Expo modules, CLI, and Metro config. Teams committed to vanilla React Native will feel friction. Migrating an existing React Navigation app is a real refactor, not a drop-in, since you move from imperative navigator config to file-system conventions and rethink your URL structure. And the shiniest pieces, native tabs and the declarative Stack.Toolbar, are alpha and carry unstable- import paths, so pin your expectations to your SDK version.
The Takeaway
Expo Router is what React Native navigation should have been from the start: routing as a convention rather than a configuration chore. You get deep links, typed routes, web rendering, and native-quality transitions essentially for free, all from a folder of files that reads like a sitemap. For web developers crossing into mobile, it offers a familiar app/ directory mental model. For Expo teams, it is the first-party path that plugs straight into EAS Hosting, Expo Updates, and the newest native UX. Drop a file in app/, and you have a screen. It really is that pleasant.