Expo Router's Real Payoff: Your Route Table Becomes a Build-Time Artifact
Expo Router's real advantage isn't the folder convention — it's that static route analysis turns your route table into a build-time artifact that drives deep links, typed params, and bundle splitting.
13 Dec 2025, 16:48 UTC

Most React Navigation apps describe the same screen twice. Once in code, as a stack of Screen components. Once again in a linking config object that maps URL paths to screen names. Add a screen, forget the second entry, and the deep link either opens nothing or lands somewhere unexpected — and nothing in the type system complains.
Expo Router's pitch is that this duplication disappears. The interesting part isn't the folder naming convention. It's that a file tree can be read without running your app, which turns your route table into something the build step can inspect.
What static route analysis actually changes
In Expo Router (recommended navigation for Expo SDK 49 and later), every file under app/ maps to a route. Because that mapping is a convention rather than a runtime registration, the bundler can enumerate the full route set at build time. That enumeration feeds three separate things:
- Deep-link configuration. Expo's config plugins derive the native scheme entries (
ios.infoPlist.CFBundleURLTypes, Android intent filters) fromapp.json/app.config.jsplus the route tree, so you stop hand-editing native project files. - Generated TypeScript types. Route names and segment params become typed, so
router.push('/user/42')is checked rather than guessed. - Per-route bundle splitting. Known routes can be chunked individually instead of shipping one monolithic bundle.
Underneath, Expo Router wraps React Navigation v6. Stack, tabs, and drawer navigators behave as before, and you can still drop down to useNavigation() or useRouter() when a file convention doesn't fit the interaction you need.
Worked example: a route group plus a dynamic segment
This is the pattern that trips people up first, because it looks like it should add a URL segment and doesn't.
app/
_layout.tsx
(auth)/
_layout.tsx
login.tsx
register.tsx
user/
[id].tsx
Parentheses mark a route group: the folder shares (auth)/_layout.tsx without contributing to the path. The resulting URLs are /login and /register, not /auth/login. That's the whole point — you get a shared layout (headers, auth guards, transition config) without polluting your public URL space.
The dynamic segment reads its param through a typed hook:
import { Text } from 'react-native';
import { useLocalSearchParams } from 'expo-router';
export default function UserScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
return <Text>User {id}</Text>;
}
Run these from the project root, with Node and npm available:
- Scaffold a reference project:
npx create-expo-app@latest --template router, then read the generatedapp/_layout.tsxto see how the root layout wraps the navigator. - Regenerate route types. The exact command has moved between SDK versions, so check
npx expo --helpor the Expo Router docs for your SDK rather than assuming. Then openexpo-router.d.tsand confirm your new route appears. - Build a development client —
npx expo run:iosneeds macOS and Xcode;npx expo run:androidneeds the Android SDK — and openmyapp://user/42.
Expected check: UserScreen mounts and renders User 42. If the link opens the app but not that screen, the usual cause is a stale native build — the scheme in app.json is compiled into the binary by the config plugin, so changing it requires a rebuild, not just a JS reload.
Where the approach gets awkward
File-based routing assumes a single-page-app mental model, and that assumption has costs:
- Deep trees get verbose. A path like
/user/[id]/post/[postId]means nested folders and layout files. A centralized route config is easier to scan once nesting passes two or three levels. - Type generation is a step you can forget. Adding a route without regenerating produces TypeScript errors that read like real bugs to someone new to the project.
- Server-first rendering is web-only. It's experimental from SDK 51 onward, and native iOS/Android builds ignore the server output entirely — any SEO or first-paint benefit requires a separate web deployment. Treat version-specific behavior as something to confirm against the Expo changelog.
- Migration is manual. Moving a mature app means relocating screens into
app/and replacing navigation refs withrouter.push()/router.back(). It can be done incrementally, but nothing automates it. - Native stack manipulation can conflict. Custom native modules that touch
react-native-screensAPIs directly may fight Expo Router's internal navigation container. Test that combination early if you depend on it.
How to verify before committing
Four checks, in order of cost:
- Confirm the generated types match your file tree — this catches convention mistakes before any native build.
- Open a deep link on a real dev client and confirm the right screen mounts with the right params.
- For web, set
web.outputto"server"inapp.json, runnpx expo export --platform web, and inspect the generatedserver/directory for pre-rendered HTML. If your hosting setup can't serve it, revertweb.outputto its previous value — that field changes build behavior, so it's worth changing deliberately. - Check the Expo changelog for your SDK before relying on route groups or server output in production.
The decision isn't really "file-based versus imperative." It's whether you want your route table to be an artifact the build can read. If you do, the folder conventions and the regeneration step are the price — and typed params plus automatic deep-link config are what you get back.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.