Back to Blog
Mar 17, 20264 min readOnuzulike Anthony

Expo Router: File-Based Navigation for React Native

Layout routes, tab navigation, deep links, authentication guards, and shared transitions in Expo Router v3.

MobileExpoReact NativeNavigation

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:

tsx
// 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:

tsx
// 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

tsx
// 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>
  );
}
tsx
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.jsonrouter.push will error on unknown paths.

Dynamic Routes and Params

tsx
// 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;
  // ...
}

Expo Router auto-generates the deep link configuration from your file structure. Configure the scheme in app.json:

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:

tsx
// 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)/index is just / when linked externally
  • _layout.tsx files are cumulative — each nested layout wraps its children in addition to parent layouts
  • useLocalSearchParams is 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.