Expo Router File‑Based Routing: Conventions, Deep‑Linking, and Platform‑Specific Gotchas
Expo Router turns the app/ directory into a route map, but placement, optional‑parameter semantics, and dev‑server refresh quirks can trip a migration. This post walks through conventions, shows a typed dynamic route example, and highlights native‑vs‑web parameter mismatch you must guard against.
04 Apr 2026, 04:49 UTC

The problem: routing that feels like a second codebase
React Navigation lets you define stacks and tabs with code, but every new screen requires a route name, a parameter list and linking configuration. As the app grows, that configuration file becomes its own maintenance surface. Expo Router (SDK 49+) proposes to eliminate that duplication by making the app/ directory the route map: conventions replace configuration. The appeal is real, yet the migration path hides a few platform‑specific edges that can stall a rollout.
1. File‑based routing fundamentals
Every file under app/ becomes a route segment. Folders named (group) group screens without adding a URL segment, while +html.tsx provides a web‑only entry point. The router reads this tree at startup and builds a manifest that the dev server serves to the client.
- Root requirement: the
app/folder must sit at the project root (or be pointed to viaexpo.router.rootinapp.json). Monorepos with multiple Expo apps need a separateapp/per package. - Layout files:
_layout.tsxmust export a default React component; named exports or barrel files break route resolution.
2. Nested layouts and state persistence
Expo Router offers two primary layout conventions: (tabs) and (stack). A (tabs) layout keeps each tab’s navigation stack alive when you switch between tabs, so scroll position and back‑history persist without extra code. A (stack) layout mounts and unmounts screens per route, which means navigating back re‑creates the screen unless you keep it alive with useFocusEffect or similar patterns.
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
export default function TabLayout() {
return (
);
}
With this layout, switching from home to settings does not reset the home stack, a behavior that previously required manual NavigationContainer nesting in React Navigation.
3. Deep linking and type‑safe parameters
A dynamic file such as app/profile/[id].tsx automatically handles both /profile/123 (web) and exp://.../profile/123 (native). When TypeScript is enabled and the generated type declarations are present, the id parameter appears as a required, typed prop on the route component.
// app/profile/[id].tsx
import { useLocalSearchParams } from 'expo-router';
export default function Profile() {
const { id } = useLocalSearchParams<{ id: string }>();
return (
Profile: {id}
);
}
Hovering over id in VS Code (with the project’s TypeScript configuration) shows the type string and the compiler expects it to be provided via the URL.
4. Optional‑parameter asymmetry between native and web
Catch‑all segments like app/docs/[...slug].tsx behave differently when no segments are present: on native the param may be undefined, while on web the param can be an empty array. Assuming the same shape on both platforms can cause rendering bugs.
The pragmatic fix is to normalize the param early in the component:
const { slug } = useLocalSearchParams<{ slug: string[] }>();
const normalizedSlug = Array.isArray(slug) ? slug : [];
Test the route on both npx expo start --web and a device or simulator before shipping.
Actionable closing: validate the migration in three steps
- Spin up a fresh
router-tabstemplate and confirm the generatedapp/structure matches your mental model. - Port one existing screen to a file‑based route, add a dynamic param, and run the deep‑link test on both iOS and Android using
npx uri-scheme open 'exp://localhost:8081/details/42' --ios(and--androidon Android). - Run the web build (
npx expo export --platform web) and verify that optional‑parameter routes render without crashes.
If all three checks pass, you have a reliable baseline to migrate the rest of the navigation tree screen‑by‑screen — no full rewrite required.