Stop Hand-Wiring React Navigation: What Expo Router Actually Buys You
Expo Router replaces hand-wired React Navigation setup with file-based routes, free deep links, and typed navigation. Here's a working stack-plus-tabs example and the real trade-offs.
19 Nov 2025, 01:50 UTC

If you've built a React Native app with plain React Navigation, you know the drill: register every screen in a Stack.Screen, keep a parallel linking config in sync for deep links, and pass route names around as bare strings that TypeScript can't check. Rename a screen, forget one of the three places it's referenced, and you find out at runtime — usually in a screen recording from a tester.
Expo Router's answer is to make the filesystem the source of truth. Files under an app/ directory become routes automatically. This post explains what that actually buys you, where it hurts, and shows a working stack-plus-tabs layout you can adapt.
Files are routes, and that's the whole trick
Expo Router is built on top of React Navigation, not instead of it. You still get Stack and Tabs navigators underneath; you just stop registering screens by hand. The mapping is direct:
app/index.tsx→/app/profile/[id].tsx→/profile/:id(dynamic segment)app/_layout.tsx→ a layout wrapping sibling routes, where you export a<Stack />or<Tabs />
Because every file is a URL path, every route gets a deep link for free. In a hand-wired React Navigation app, deep linking means maintaining a separate linking.config object that mirrors your navigator tree — a second source of truth that drifts. With Expo Router, the route table is the linking config. Opening myapp://profile/42 on a simulator renders the profile screen with id = "42", no extra setup. The same mechanism powers Expo's web support, so mobile and web share one navigation model.
Typed routes kill the stringly-typed bug class
The feature that sells most teams is typed routes. When enabled, Expo Router generates TypeScript types from your app/ directory, so this fails at compile time:
import { router } from 'expo-router';
// Type error: '/profle/123' is not a valid route
router.push('/profle/123');
// OK — checked against app/profile/[id].tsx
router.push('/profile/123');Rename app/profile/[id].tsx to app/user/[id].tsx and every stale router.push('/profile/...') lights up red in your editor. That's the entire class of "navigated to a route that doesn't exist" bugs, gone before runtime. Typed routes matured across the SDK 49–52 era, so check the docs for your SDK version — the opt-in flag location has moved between releases.
A worked example: stack wrapping tabs
The canonical layout — a root stack containing a tab group, plus a modal-style detail screen — looks like this on disk:
app/
_layout.tsx // root Stack
profile/[id].tsx // pushed over the tabs
(tabs)/
_layout.tsx // Tabs navigator
index.tsx // Home tab
settings.tsx // Settings tabapp/_layout.tsx:
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="profile/[id]" options={{ title: 'Profile' }} />
</Stack>
);
}app/(tabs)/_layout.tsx:
import { Tabs } from 'expo-router';
export default function TabLayout() {
return (
<Tabs>
<Tabs.Screen name="index" options={{ title: 'Home' }} />
<Tabs.Screen name="settings" options={{ title: 'Settings' }} />
</Tabs>
);
}Two things worth noticing. First, (tabs) is a route group: the parentheses mean it organizes files and gets its own layout without adding a path segment. The home screen is at /, not /(tabs)/. This is the most commonly misunderstood convention in the library — groups are for structure and shared layouts (an (auth) group with a guard layout is the classic use), not URLs.
Second, the detail screen lives outside the group, so it pushes over the tabs with the stack's header instead of rendering inside a tab. That layout decision is expressed by where the file sits, which is either elegant or too implicit, depending on your taste.
The trade-off: convention over control
File-based routing is a real constraint, not just a preference. If your navigation tree is highly dynamic — routes gated by permissions, A/B-driven flows, screens registered from a server-driven config — forcing it into a static directory structure gets awkward. You'll end up with catch-all routes and conditional layouts that are harder to read than the explicit config you replaced.
Debugging also feels indirect. When a screen renders with the wrong header, the cause is a convention (which _layout.tsx wraps this file?) rather than a line of configuration you can grep for. And version drift is a genuine hazard: expo-router is versioned semi-independently of the Expo SDK, and a mismatched pair produces confusing errors. Before publishing or upgrading, check your installed expo-router version against the compatibility table for your SDK with npx expo-doctor or the SDK's bundled package versions.
One thing Expo Router does not give you is performance. It adds a layer over React Navigation; the value proposition is developer experience and free deep linking, not speed.
Try it in ten minutes
Create a project with the current SDK's router template (npx create-expo-app@latest and pick a template that includes Expo Router), reproduce the layout above, then verify the three claims that matter: navigate between tabs, open /profile/7 via a deep link in the simulator, and mistype a router.push() path to watch TypeScript complain. If all three behave as described, the convention is earning its keep. If your app's navigation needs are weird enough that you're fighting the filesystem, plain React Navigation is still right there underneath — and now you know exactly what you'd be giving up.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.