file-based routing in practice
Expo Router replaces manually wired navigators with the app/ directory as the single source of truth. Nested layouts via _layout.tsx, dynamic segments like [id].tsx, route groups with parentheses, and deep linking that works automatically make React Native navigation predictable and type safe.
Table of contents
- 1. What Expo Router actually solves
- 2. The app/ directory: files as routes
- 3. _layout.tsx: nested navigation
- 4. Dynamic segments: [id].tsx and parameters
- 5. Route groups: (tabs) without a URL segment
- 6. Catch-all routes: [...missing].tsx
- 7. Typed Routes: type safety for navigation
- 8. useRouter and useLocalSearchParams
- 9. Deep linking and migrating from React Navigation
- 10. Summary
- 11. FAQ
1. What Expo Router actually solves
Expo Router is not another navigator sitting next to Stack and Tabs, it is a convention: the folder structure under app/ directly describes which screens an app has and how they relate to each other. Anyone who has worked with React Navigation before knows the pattern where every new screen has to be registered in three places, as a component, as an entry in the navigator configuration, and often also in a separate type definition for the parameter list. Expo Router reduces that to a single step: create a file in app/, and the route exists.
The real problem Expo Router addresses is not convenience, it is consistency between three things that tend to drift apart in classic React Native apps: the visible navigation structure, the URL structure used for deep links, and the type definition of the navigation parameters. With manually wired navigators, all three need to be kept in sync by hand, and every change to a screen name requires edits in several places. With Expo Router the file path is simultaneously the navigation structure and the basis for the URL, which removes an entire class of synchronization bugs before it can occur.
One detail that is often underestimated: Expo Router is built on top of React Navigation, it does not replace it. It generates the navigator configuration at runtime from the file structure. Anyone familiar with React Navigation internals can still use them directly, for example through unstable_settings or by reaching into the underlying navigator. The difference shows up in the experience of adding new screens, not in the navigation logic itself.
2. The app/ directory: files as routes
Every file under app/ automatically becomes a route with Expo Router. app/index.tsx is the app's home screen, app/settings.tsx maps to the route /settings, and a subfolder such as app/product/details.tsx maps to /product/details. This mapping is not additional configuration, it is the direct result of the file system structure. For teams coming from web frameworks with a similar convention this behaviour feels familiar right away, for teams coming from classic React Native it is usually the biggest adjustment.
It matters that Expo Router distinguishes between ordinary route files and special files that start with an underscore, such as _layout.tsx. These special files do not create a route of their own, instead they define how the routes in their directory are rendered. An example structure for a small shop app could look like this: app/_layout.tsx for the root layout, app/(tabs)/index.tsx for the home screen, app/product/[id].tsx for the product detail page, and app/cart.tsx for the shopping cart. Each of these files is its own module, only loaded once the matching route is actually visited.
This structure has a direct effect on bundle size: because every route is its own module, Expo Router can perform route-level code splitting together with the Metro bundler, especially on the web export. Classic React Navigation apps, on the other hand, typically import every screen component upfront in the initial navigator setup, even if a user never navigates to that route at all. For mobile commerce apps with many product categories and special-offer pages that difference is noticeable in initial load time.
# Create a new Expo project with the router template pre-installed
npx create-expo-app@latest my-shop-app --template tabs
cd my-shop-app
# The app/ directory is the single source of truth for navigation
find app -type f | sort
# app/_layout.tsx
# app/(tabs)/_layout.tsx
# app/(tabs)/index.tsx
# app/(tabs)/categories.tsx
# app/(tabs)/cart.tsx
# app/product/[id].tsx
# app/[...missing].tsx
# Start the dev server, deep links work immediately in Expo Go
npx expo start
3. _layout.tsx: nested navigation
The _layout.tsx file is the heart of the navigation hierarchy in Expo Router. It exports a component that typically returns Stack, Tabs, or Drawer from the expo-router package, defining how all child routes in the same directory are presented. A root layout under app/_layout.tsx wraps the entire app, another layout under app/(tabs)/_layout.tsx defines only the tab bar for the routes it contains. This nesting follows the folder structure exactly, a glance at the file system shows the app's complete navigation hierarchy immediately.
The practical advantage over manually composed navigators shows up in typical mobile commerce structures: a tab bar for home, categories, cart, and account, but inside the categories tab a stack navigator for category list, product list, and product detail. With React Navigation this is built with a Tab.Navigator that contains individual Stack.Navigator instances as screen components, all defined in one central file. With Expo Router the same structure emerges on its own: a _layout.tsx with Tabs inside the (tabs) folder, and inside it another subfolder with its own _layout.tsx that defines a Stack.
// app/_layout.tsx - root layout, wraps the entire app
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="product/[id]" options={{ headerShown: true, title: 'Product' }} />
<Stack.Screen name="[...missing]" options={{ title: 'Not found' }} />
</Stack>
);
}
4. Dynamic segments: [id].tsx and parameters
Dynamic routes are also a pure file naming convention with Expo Router: a file with square brackets in its name, such as app/product/[id].tsx, matches any value at that position in the path, so /product/123 as much as /product/abc-sneaker. The bracketed part of the file name simultaneously becomes the name of the parameter under which the value can later be read through a hook. This one-to-one relationship between file name and parameter name is deliberate, so there is no separate place where parameters and routes need to be kept in sync.
Multiple dynamic segments can be nested, for example app/store/[storeId]/product/[productId].tsx for a structure with several sales channels. Each segment resolves independently, and both values are available in the same object when the screen reads its parameters. For a mobile commerce app talking to a Magento instance over REST or GraphQL this is directly usable: the product id from the path is passed straight through as a parameter to the API call against the storefront endpoint, with no extra mapping layer between navigation and data.
One detail that is often overlooked in practice: dynamic segments are always strings by default, even when the value in the path looks like a number. Anyone passing a numeric product id to an API client that expects a number has to convert it explicitly. This behaviour is consistent with the URL-based nature of Expo Router, since path segments are fundamentally text on the web as well.
// app/product/[id].tsx - dynamic segment reads its value via useLocalSearchParams,
// useRouter navigates onward after a successful action
import { View, Text, Pressable } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useProduct } from '../../hooks/useProduct';
import { useCart } from '../../hooks/useCart';
export default function ProductDetailScreen() {
// "id" matches the file name [id].tsx exactly
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const { product, isLoading } = useProduct(id);
const { addItem } = useCart();
if (isLoading || !product) {
return <Text>Loading product...</Text>;
}
const handleAddToCart = () => {
addItem(product.id, 1);
// Navigate to the cart route after the item was added
router.push('/cart');
};
return (
<View>
<Text>{product.name}</Text>
<Text>{product.price}</Text>
<Pressable onPress={handleAddToCart}>
<Text>Add to cart</Text>
</Pressable>
</View>
);
}
5. Route groups: (tabs) without a URL segment
Route groups are one of the least intuitive but most useful features of Expo Router. A folder whose name is wrapped in parentheses, such as (tabs), is used for the navigation hierarchy and for layout purposes, but does not appear as a segment in the resulting URL. app/(tabs)/index.tsx therefore maps to the route /, not /(tabs)/. Route groups solve a problem that would otherwise be hard to address cleanly: a shared tab bar for several top-level routes, without that grouping polluting the publicly visible URL structure.
In practice, route groups are also used to build several independent root layouts, for example a group (auth) for login and registration without a tab bar, and a group (tabs) for the logged-in area with tab navigation. Both groups sit directly under app/, each with its own _layout.tsx, and Expo Router decides which group is currently rendered based on the authentication state. This separation without any URL impact is technically possible with classic React Navigation setups too, but it requires considerably more manual conditional logic in the root navigator.
// app/(tabs)/_layout.tsx - tab bar for the logged-in area,
// the "(tabs)" segment itself never appears in the URL
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabsLayout() {
return (
<Tabs screenOptions={{ tabBarActiveTintColor: '#4338ca' }}>
<Tabs.Screen
name="index"
options={{ title: 'Home', tabBarIcon: (props) => <Ionicons name="home" {...props} /> }}
/>
<Tabs.Screen
name="categories"
options={{ title: 'Categories', tabBarIcon: (props) => <Ionicons name="grid" {...props} /> }}
/>
<Tabs.Screen
name="cart"
options={{ title: 'Cart', tabBarIcon: (props) => <Ionicons name="cart" {...props} /> }}
/>
</Tabs>
);
}
6. Catch-all routes: [...missing].tsx
For paths that do not match any defined route, Expo Router offers catch-all routes, marked with three dots inside the square brackets, such as app/[...missing].tsx. This file catches every path that is not served by a more specific route, making it the direct equivalent of a 404 page on the web, translated to a native app. Without this route, an invalid deep link, for example from a push notification with an outdated product id, would lead to an undefined state or a crash.
The captured path is also available as a parameter inside the catch-all route, usually as an array of the individual path segments. That makes it possible to log exactly which path could not be resolved on the error screen, which helps considerably when debugging production apps with many externally linked deep links. This is especially relevant for a mobile commerce app when product ids change or categories get restructured, while old links from email campaigns or social media posts stay in circulation.
7. Typed Routes: type safety for navigation
Typed Routes are a feature of Expo Router that automatically generates TypeScript types for all valid paths from the existing file structure under app/. The feature is enabled through the experiments setting in app.json, after which the development server regenerates the matching type definitions every time a new route file is saved. A call to router.push with a path that does not exist, or with missing required parameters, is then flagged as an error at compile time already, not first at runtime on the device.
This difference to manually maintained type definitions matters a lot in practice. With React Navigation a developer has to maintain a RootStackParamList by hand and keep it in sync with the actual navigator configuration for every new route, a source that regularly drifts apart in larger teams. With Typed Routes there is only one source of truth, the file system itself, from which the types are derived mechanically. A typo in the path, for example /produtc/123 instead of /product/123, becomes visible during development this way, not first through a bug report from production.
{
"expo": {
"name": "my-shop-app",
"scheme": "myapp",
"experiments": {
"typedRoutes": true
},
"plugins": [
"expo-router"
]
}
}
8. useRouter and useLocalSearchParams
Expo Router provides the useRouter hook for programmatic navigation, offering methods such as push, replace, back, and setParams. Unlike React Navigation, where the navigation prop usually has to be passed down through several component levels, useRouter can be called directly inside any component within a route, no matter how deeply it is nested in the component hierarchy. To read parameters from the current path, whether a dynamic segment or a query string, the useLocalSearchParams hook is used.
In practice both hooks are frequently combined in the same component, as shown in the code example in section 4: useLocalSearchParams reads the product id from the path, a data-loading step then fetches the product details based on it, and useRouter navigates onward after a successful action, such as adding an item to the cart, straight to the cart route. Because both hooks operate directly on the current navigation state, there is no need to manually pass navigation and route props down, something that gets messy quickly in deeply nested React Navigation component trees.
Besides push, useRouter also offers replace, which replaces the current route in the history instead of adding a new one, useful for example after a login so the back button does not lead straight back to the login screen. setParams allows changing the query parameters of the current route without triggering a full navigation, which fits filter and sort state on category pages without remounting the rest of the screen.
9. Deep linking and migrating from React Navigation
Deep linking works with Expo Router without any additional configuration, because every route already has a unique path. With classic React Navigation, a separate linking configuration object has to be maintained that maps every screen name to a URL pattern, including nested navigators whose paths have to be assembled by hand. With Expo Router that mapping is already given by the file structure, an external link to myapp://product/123 or a universal link to https://shop.example.com/product/123 opens the matching route directly, including correctly resolved parameters.
For teams migrating an existing React Navigation app, a gradual approach is recommended instead of a full rewrite: first, expo-router is installed and a minimal root layout is created that maps the existing top-level screens to routes under app/, while the inner screen components keep working unchanged for now. Navigation calls are then switched over gradually from navigation.navigate to router.push, route by route, team by team. Only once every screen has been migrated are the manually maintained param list and the linking configuration removed.
The following comparison summarizes the key differences between manually wired React Navigation and file-based Expo Router, especially for teams that need to make a migration decision.
| Aspect | React Navigation (manually wired) | Expo Router (file-based) | Advantage |
|---|---|---|---|
| Navigator setup | Compose Stack/Tab navigator manually in a central file | Create a file in app/, route exists automatically | No boilerplate, structure equals navigation |
| Deep linking | Maintain a separate linking config object | Follows automatically from the file path | No manual path mapping |
| Typed parameters | Keep RootStackParamList in sync by hand | Typed Routes generate types from app/ | No sync errors between code and types |
| Code splitting | All screens imported in the initial navigator | Every route is its own, lazily loaded module | Smaller initial bundle |
| Nested layouts | Compose navigators inside navigators by hand | _layout.tsx mirrors the folder structure | Hierarchy is visible in the file system |
Mironsoft
React Native apps and mobile commerce storefronts for Magento
A React Native app with clean navigation for your Magento store?
We build React Native apps with Expo Router and connect them to existing Magento instances over REST or GraphQL APIs, from product detail pages with dynamic routes to deep links from push notifications and marketing campaigns.
Code review
Reviewing existing Expo Router or React Navigation structures for maintainability and type safety
App architecture
Setting up nested layouts, route groups, and typed routes for scalable mobile commerce apps
API integration
Connecting Magento REST and GraphQL endpoints directly to dynamic routes and product pages
10. Summary
Expo Router solves the fundamental problem of manually wired navigation by making the file system itself the single source of truth. The app/ directory defines the routes, _layout.tsx files define nested navigation, dynamic segments like [id].tsx pick up parameters directly from the path, and route groups with parentheses allow grouping without any effect on the URL. Catch-all routes cleanly capture invalid paths, Typed Routes generate navigation types automatically from the folder structure, and deep linking works without separate configuration.
For teams migrating from React Navigation, the gradual path through a minimal root layout is the lowest-risk approach, since existing screen components can keep working unchanged at first. The biggest effect shows up long term in maintainability: because navigation structure, URL structure, and type definitions all come from the same source, the class of synchronization bugs that manually wired navigators regularly produce simply does not arise with Expo Router.
Expo Router: file-based routing, the essentials at a glance
app/ directory
Every file under app/ automatically becomes a route. No central navigator configuration needed, the file system is the navigation structure.
Nested layouts
_layout.tsx per folder level defines Stack, Tabs, or Drawer. The nesting follows the folder structure exactly.
Dynamic segments & Typed Routes
[id].tsx delivers parameters directly from the path, Typed Routes automatically generate TypeScript types from app/.
Deep linking & migration
Deep links work without separate configuration. Migrating from React Navigation succeeds gradually through a minimal root layout.