Expo Router: File-Based Routing for React Native
Expo Router brings the power of file‑based routing, popular in Next.js, to React Native projects built with Expo. Instead of manually configuring screens and navigation stacks, you simply drop a component file into a folder and let the router handle the rest. This approach reduces boilerplate, improves code organization, and makes deep linking a breeze. In this article we’ll explore the core concepts, walk through a couple of hands‑on examples, and share pro tips for scaling your navigation architecture.
Getting Started with Expo Router
The first step is to install the expo-router package and enable the new navigation paradigm. Run the following command in your Expo project:
npx expo install expo-router
Next, add a router folder at the root of your project. Inside this folder, each .tsx (or .js) file becomes a route. For example, router/index.tsx maps to the home screen, while router/profile.tsx maps to /profile. The router automatically generates a Stack navigator for you, so you don’t need to wrap your screens in NavigationContainer or define a createStackNavigator manually.
Folder Structure Overview
- router/ – Root folder for all route files.
- ├─ index.tsx – Home screen (default route).
- ├─ profile.tsx – Simple profile page.
- ├─ settings/ – Nested folder for sub‑routes.
- │ └─ index.tsx –
/settingsscreen. - │ └─ notifications.tsx –
/settings/notifications.
With this layout, the router infers the navigation hierarchy: /settings is a child of the root stack, and /settings/notifications is a child of /settings. No extra configuration is required.
Defining a Basic Screen
Let’s create a minimal home screen. Inside router/index.tsx, export a default React component. The router will treat the default export as the screen component.
import { View, Text, Button } from 'react-native';
import { Link } from 'expo-router';
export default function HomeScreen() {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ fontSize: 24, marginBottom: 20 }}>Welcome to Expo Router!</Text>
<Link href="/profile">
<Button title="Go to Profile" />
</Link>
</View>
);
}
The Link component works like a web anchor tag: it triggers navigation without the need for navigation.navigate. Under the hood, Expo Router translates the href into a stack push.
Creating a Profile Screen with Params
Sometimes you need to pass data via the URL. Create router/profile/[userId].tsx to capture a dynamic segment. The file name inside brackets tells the router to treat that part as a parameter.
import { View, Text } from 'react-native';
import { useLocalSearchParams } from 'expo-router';
export default function ProfileScreen() {
const { userId } = useLocalSearchParams();
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ fontSize: 22 }}>Profile of User: {userId}</Text>
</View>
);
}
Now you can navigate to /profile/42 and the screen will render “Profile of User: 42”. This pattern eliminates the need for a separate params object in navigate calls.
Pro tip: Keep dynamic route files in a dedicated _dynamic folder if you have many of them. It makes the folder tree cleaner and improves discoverability.
Nested Routes and Layouts
Real‑world apps often require nested navigators, such as a settings stack inside a main tab navigator. Expo Router supports nested routes using _layout.tsx files. A layout file wraps all child routes in the same folder, allowing you to share UI like headers or tab bars.
Create router/settings/_layout.tsx to define a common layout for all settings screens.
import { Stack } from 'expo-router';
import { View, Text } from 'react-native';
export default function SettingsLayout() {
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: '#6200ee' },
headerTintColor: '#fff',
}}
>
{/* Child routes will be rendered here automatically */}
</Stack>
);
}
Inside the same folder, add index.tsx for the main settings page and notifications.tsx for a sub‑screen. Both inherit the header styling defined in the layout.
import { View, Text, Button } from 'react-native';
import { Link } from 'expo-router';
export default function SettingsHome() {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ fontSize: 20, marginBottom: 12 }}>Settings</Text>
<Link href="/settings/notifications">
<Button title="Notification Preferences" />
</Link>
</View>
);
}
When the user taps the button, the router pushes /settings/notifications onto the same stack, preserving the shared header.
Pro tip: Use Stack.Screen inside a layout only when you need custom options per screen. Otherwise, let the router auto‑generate screens for a cleaner codebase.
Deep Linking and URL Schemes
One of the biggest advantages of file‑based routing is native support for deep linking. Because each route corresponds to a URL path, you can open any screen from an external source—email, push notification, or another app—by using the standard Expo linking configuration.
In app.json, add a custom scheme and configure the expo field:
{
"expo": {
"scheme": "myapp",
"plugins": ["expo-router"]
}
}
Now a link like myapp://settings/notifications will launch the app directly to the notifications screen, even if the app was previously closed. The router parses the URL, matches it to the corresponding file, and renders the correct component automatically.
Handling Query Parameters
Query strings work seamlessly with file‑based routes. Suppose you want to pre‑fill a search screen with a term. Create router/search.tsx and read the query using useSearchParams.
import { View, TextInput } from 'react-native';
import { useSearchParams } from 'expo-router';
import { useState, useEffect } from 'react';
export default function SearchScreen() {
const { q } = useSearchParams();
const [term, setTerm] = useState(q ?? '');
useEffect(() => {
// You could trigger a fetch here whenever `term` changes
}, [term]);
return (
<View style={{ flex: 1, padding: 20 }}>
<TextInput
placeholder="Search..."
value={term}
onChangeText={setTerm}
style={{ borderWidth: 1, borderColor: '#ccc', padding: 10 }}
/>
</View>
);
}
Opening myapp://search?q=react%20native will render the screen with “react native” already typed into the input field.
Advanced Patterns: Conditional Routing & Middleware
Expo Router supports middleware files that run before a route resolves. This is perfect for authentication checks, feature flags, or analytics. Place a _middleware.tsx file in any folder to intercept navigation to its children.
import { redirect } from 'expo-router';
import { useEffect } from 'react';
import { useAuth } from '../hooks/useAuth';
export default function SettingsMiddleware({ children }) {
const { isLoggedIn } = useAuth();
useEffect(() => {
if (!isLoggedIn) {
redirect('/login');
}
}, [isLoggedIn]);
return children;
}
The above middleware ensures that any attempt to access /settings/* routes redirects unauthenticated users to the login screen. Because the middleware lives next to the route files, the logic stays close to where it’s applied.
Conditional Layouts
Sometimes you need a different layout based on user roles. Create two layout files—_layout.admin.tsx and _layout.user.tsx—and decide which one to render inside a top‑level _layout.tsx that checks the role.
import { useAuth } from '../hooks/useAuth';
import AdminLayout from './_layout.admin';
import UserLayout from './_layout.user';
export default function RootLayout({ children }) {
const { role } = useAuth();
if (role === 'admin') {
return <AdminLayout>{children}</AdminLayout>;
}
return <UserLayout>{children}</UserLayout>;
}
This pattern keeps role‑specific UI (like extra admin tabs) encapsulated, while the rest of the app shares a common navigation stack.
Pro tip: Keep middleware lightweight. Heavy async work (e.g., API calls) should be offloaded to a background store or use React Query, otherwise navigation may feel sluggish.
Testing Routes with Expo Router
Testing navigation is straightforward because routes are just components. Use React Native Testing Library to render a route and assert that the correct UI appears after navigation.
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import { Router } from 'expo-router';
import HomeScreen from '../router/index';
test('navigates to profile on button press', async () => {
const { getByText } = render(<Router><HomeScreen /></Router>);
fireEvent.press(getByText('Go to Profile'));
await waitFor(() => getByText(/Profile of User:/));
});
The test renders the home screen inside the router context, triggers the Link, and verifies that the profile screen appears. Because the router handles URL parsing internally, you don’t need to mock navigation objects.
Performance Considerations
File‑based routing lazily loads each screen by default, which reduces the initial bundle size. However, if you have a very deep hierarchy, the router may generate many small bundles, leading to a slight increase in runtime overhead. To mitigate this, use the static export in a route file to pre‑bundle critical screens.
export const unstable_static = true; // Forces static bundling for this screen
Mark only the most frequently accessed screens (e.g., home, login) as static. The rest remain lazy‑loaded, preserving a fast start‑up time while still delivering smooth navigation.
Real‑World Use Case: E‑Commerce App
Imagine building a mobile shop where users browse categories, view product details, and manage a cart. With Expo Router, the folder layout mirrors the user flow:
- router/
- index.tsx – Home page with featured products.
- category/[slug].tsx – Dynamic category list.
- product/[id].tsx – Product detail screen.
- cart/_layout.tsx – Cart stack with checkout steps.
- cart/checkout.tsx – Payment screen.
- order/[orderId].tsx – Order confirmation.
Deep linking becomes trivial: a marketing email can include myshop://product/12345, and the app opens directly to that product’s page. Middleware in cart/_middleware.tsx can verify that the user is logged in before allowing checkout, redirecting to /login if necessary. The resulting codebase stays clean, and navigation logic lives alongside the UI components it serves.
Migration from React Navigation
If you’re already using React Navigation, the migration path is painless. Keep your existing screens, move them into the router folder, and replace navigation.navigate calls with Link or router.push. The router still uses React Navigation under the hood, so any custom navigators you defined (e.g., drawer, bottom tabs) can be retained as layout files.
For instance, a bottom tab layout can be defined in router/_layout.tsx:
import { Tabs } from 'expo-router';
export default function RootLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#6200ee',
}}
>
{/* Children are auto‑generated from files in the root folder */}
</Tabs>
);
}
All route files at the root level become tabs automatically, eliminating the need for a separate createBottomTabNavigator call.
Best Practices Checklist
- Keep routes shallow. Deeply nested folders can make URL paths long and harder to maintain.
- Leverage dynamic segments. Use
[param]filenames for IDs, slugs, or any variable part of the path. - Group shared UI in layouts. Headers, tab bars, and side menus belong in
_layout.tsxfiles. - Use middleware sparingly. Only guard routes that truly need protection (auth, premium features).
- Mark critical screens static. Improves start‑up performance for high‑traffic pages.
- Write tests against the router. Ensures navigation flows stay reliable after refactors.
Conclusion
Expo Router transforms navigation in React Native from a manual, error‑prone process into a declarative, file‑driven experience. By aligning your folder structure with the app’s URL schema, you gain automatic deep linking, reduced boilerplate, and clearer separation of concerns. Whether you’re building a simple utility app or a full‑scale e‑commerce platform, the router’s layouts, middleware, and dynamic routes give you the flexibility to scale without sacrificing readability. Adopt the patterns shared here, experiment with custom layouts, and watch your navigation code become both more maintainable and more delightful to work with.