TL;DR
-
_layout.tsxwraps 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.tsxsaves 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
// 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>
);
}
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
(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 />;
// ...
}
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 />;
}
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>
);
}
6. Turn on typed routes
{
"expo": {
"experiments": {
"typedRoutes": true
}
}
}
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
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)