DEV Community

Mason Roy
Mason Roy

Posted on

6 Expo Router Gotchas I Hit Moving From React Navigation

TL;DR

  • _layout.tsx wraps everything in its folder — put providers in the root one, not in screens.
  • (group) folders organize routes without changing the URL.
  • Dynamic params come in as strings, always. Parse them.
  • Do auth redirects in a layout, not inside every screen.
  • +not-found.tsx saves you from blank screens on bad deep links.
  • Turn on typed routes and let TypeScript catch broken links.

I moved a mid-sized app from React Navigation to Expo Router last month. File-based routing is genuinely nicer, but the mental model is different, and these six things cost me the most time.

1. Layouts are not screens

Every folder can have a _layout.tsx, and it wraps every route inside that folder.

app/
  _layout.tsx        ← root: providers, fonts, theme
  index.tsx
  settings/
    _layout.tsx      ← Stack for settings only
    index.tsx
    profile.tsx
Enter fullscreen mode Exit fullscreen mode
// app/_layout.tsx
import { Stack } from 'expo-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

export default function RootLayout() {
  return (
    <QueryClientProvider client={queryClient}>
      <Stack screenOptions={{ headerShown: false }} />
    </QueryClientProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

My mistake: I put a provider inside a screen, and it remounted on every navigation. Providers go in layouts.

2. Groups don't show up in the URL

Need tabs for logged-in users and a separate auth flow? Use parentheses:

app/
  (auth)/
    _layout.tsx
    login.tsx        → /login
  (tabs)/
    _layout.tsx
    index.tsx        → /
    feed.tsx         → /feed
Enter fullscreen mode Exit fullscreen mode

(auth) and (tabs) exist only for organization and layouts — they're invisible in routes. Watch out for two groups both defining index.tsx at the same level; that's an ambiguous route.

3. Params are always strings

// app/post/[id].tsx
import { useLocalSearchParams } from 'expo-router';

export default function Post() {
  const { id } = useLocalSearchParams<{ id: string }>();
  const postId = Number(id); // "42" → 42

  if (Number.isNaN(postId)) return <NotFound />;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Even if you navigate with router.push({ pathname: '/post/[id]', params: { id: 42 } }), you get "42" back. I lost an hour to a === comparison against a number.

4. Guard routes in the layout

Don't sprinkle auth checks across screens. Redirect once, at the group level:

// app/(tabs)/_layout.tsx
import { Redirect, Tabs } from 'expo-router';
import { useSession } from '@/hooks/useSession';

export default function TabsLayout() {
  const { session, isLoading } = useSession();

  if (isLoading) return null; // or a splash
  if (!session) return <Redirect href="/login" />;

  return <Tabs />;
}
Enter fullscreen mode Exit fullscreen mode

Every screen in (tabs) is now protected, including when someone deep-links straight to /feed.

5. Add a not-found route

Deep links from old emails or push notifications will point at routes that no longer exist.

// app/+not-found.tsx
import { Link, Stack } from 'expo-router';
import { Text, View } from 'react-native';

export default function NotFound() {
  return (
    <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
      <Stack.Screen options={{ title: 'Oops' }} />
      <Text>This screen doesn't exist.</Text>
      <Link href="/">Go home</Link>
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

6. Turn on typed routes

{
  "expo": {
    "experiments": {
      "typedRoutes": true
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Now <Link href="/sttings" /> is a TypeScript error instead of a runtime 404. This alone caught three broken links during my migration.

The folder structure I ended up with

app/
  _layout.tsx
  +not-found.tsx
  (auth)/
    _layout.tsx
    login.tsx
    signup.tsx
  (tabs)/
    _layout.tsx
    index.tsx
    feed.tsx
    settings.tsx
  post/
    [id].tsx
Enter fullscreen mode Exit fullscreen mode

If you'd rather not wire this up by hand, RapidNative generates Expo apps with this layout, auth groups and typed routes already in place — it's how I prototype new screens now.

Wrap-up

Expo Router clicks once you treat folders as navigation structure and layouts as the place where logic lives. Most of my bugs came from thinking in React Navigation terms.

What tripped you up when you switched to file-based routing? Drop a comment — especially if you've found a cleaner auth pattern.

Top comments (0)