Expo Router: File-Based Navigation for React Native
Layout routes, tab navigation, deep links, authentication guards, and shared transitions in Expo Router v3.
Expo Router brings Next.js-style file-based routing to React Native. After migrating NodWatch's mobile app from React Navigation to Expo Router, here's what landed well and what surprised me.
File Structure
app/
_layout.tsx ← root layout (fonts, providers, auth gate)
(auth)/
_layout.tsx ← auth layout (no tab bar)
login.tsx
register.tsx
(main)/
_layout.tsx ← tab bar layout
index.tsx ← home tab
search.tsx ← search tab
watchlist.tsx ← watchlist tab
downloads.tsx ← downloads tab
profile.tsx ← profile tab
movie/[id].tsx ← movie detail (deep linked)
show/[id]/
index.tsx ← show detail
season/[season].tsx ← season view
Routes map directly to file paths. (auth) and (main) are route groups — they affect the layout but not the URL.
Root Layout
The root layout handles providers, fonts, and the auth redirect:
// app/_layout.tsx
import { Stack } from "expo-router";
import { useSession } from "@nodwatch/api/auth-client";
export default function RootLayout() {
return (
<Providers>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(auth)" />
<Stack.Screen name="(main)" />
<Stack.Screen name="movie/[id]" options={{ presentation: "modal" }} />
</Stack>
</Providers>
);
}Auth Guard
Redirect unauthenticated users from the root layout:
// app/_layout.tsx (inside Providers)
import { Redirect } from "expo-router";
import { useSession } from "@nodwatch/api/auth-client";
function AuthGate({ children }: { children: React.ReactNode }) {
const { data: session, isPending } = useSession();
if (isPending) return <SplashScreen />;
if (!session) return <Redirect href="/(auth)/login" />;
return children;
}Tab Navigation
// app/(main)/_layout.tsx
import { Tabs } from "expo-router";
import { Home, Search, BookMarked, Download, User } from "lucide-react-native";
export default function TabsLayout() {
return (
<Tabs
screenOptions={{
tabBarStyle: { backgroundColor: "#000", borderTopColor: "rgba(255,255,255,0.1)" },
tabBarActiveTintColor: "#fff",
tabBarInactiveTintColor: "rgba(255,255,255,0.4)",
headerShown: false,
}}
>
<Tabs.Screen name="index" options={{ title: "Home", tabBarIcon: ({ color }) => <Home color={color} size={22} /> }} />
<Tabs.Screen name="search" options={{ title: "Search", tabBarIcon: ({ color }) => <Search color={color} size={22} /> }} />
<Tabs.Screen name="watchlist" options={{ title: "Watchlist", tabBarIcon: ({ color }) => <BookMarked color={color} size={22} /> }} />
<Tabs.Screen name="downloads" options={{ title: "Downloads", tabBarIcon: ({ color }) => <Download color={color} size={22} /> }} />
<Tabs.Screen name="profile" options={{ title: "Profile", tabBarIcon: ({ color }) => <User color={color} size={22} /> }} />
</Tabs>
);
}Navigation
import { router, Link } from "expo-router";
// Programmatic navigation
router.push("/movie/550");
router.replace("/(auth)/login");
// Declarative
<Link href="/movie/550">Fight Club</Link>Typed routes are available when you enable typedRoutes: true in app.json — router.push will error on unknown paths.
Dynamic Routes and Params
// app/movie/[id].tsx
import { useLocalSearchParams } from "expo-router";
export default function MoviePage() {
const { id } = useLocalSearchParams<{ id: string }>();
// id is typed as string | string[]
const tmdbId = Array.isArray(id) ? id[0] : id;
// ...
}Deep Links
Expo Router auto-generates the deep link configuration from your file structure. Configure the scheme in app.json:
{
"expo": {
"scheme": "nodwatch",
"deepLinking": { "prefixes": ["nodwatch://", "https://nodwatch.online"] }
}
}nodwatch://movie/550 opens app/movie/[id].tsx with id = "550". Universal links (https://nodwatch.online/movie/550) route to the same screen via the prefixes config.
Shared Element Transitions
Expo Router v3 supports shared element transitions via expo-router's SharedElement:
// On the list screen
<Link href={`/movie/${id}`} asChild>
<Pressable>
<Animated.Image
sharedTransitionTag={`movie-poster-${id}`}
source={{ uri: posterUrl }}
style={{ width: 100, height: 150 }}
/>
</Pressable>
</Link>
// On the detail screen
<Animated.Image
sharedTransitionTag={`movie-poster-${id}`}
source={{ uri: posterUrl }}
style={{ width: "100%", height: 300 }}
/>The poster animates between the list and detail view automatically — no react-navigation's SharedElement wrapper needed.
What Surprised Me
- Route groups
(name)don't appear in deep link URLs —/(main)/indexis just/when linked externally _layout.tsxfiles are cumulative — each nested layout wraps its children in addition to parent layoutsuseLocalSearchParamsis typed per-file if you use the generated types — strongly recommended- Modal presentations (
presentation: "modal") animate from the bottom on iOS by default, no extra config needed
Expo Router makes mobile navigation feel like web routing. The mental model transfer from Next.js App Router is almost 1:1.