TL;DR
- In Expo Router, the
app/folder is your navigation config. Every file you drop in there is a decision. - Most routing bugs I see aren't logic bugs. They're files in the wrong place.
- Six mistakes below, each with a before/after tree or snippet.
- Run your own project against the checklist at the end before you ship.
Why file structure is where routing breaks
With React Navigation you wrote a navigator config in code. With Expo Router you write it with folders. That's great until someone (you, a teammate, or an AI tool) drops a helper file into app/, or adds a second index.tsx inside a group, and the tree quietly says something different from what you meant.
Here's what I look for first.
1. Non-route files living inside app/
Every file in app/ is treated as a route. Put your Button.tsx or useCart.ts in there and Expo Router will try to render it as a screen and warn about a missing default export.
# Before
app/
_layout.tsx
index.tsx
Button.tsx <- becomes a route
useCart.ts <- becomes a route
# After
app/
_layout.tsx
index.tsx
components/
Button.tsx
hooks/
useCart.ts
Rule of thumb: app/ holds screens and layouts. Nothing else.
2. Two files resolving to the same URL
Route groups (folders in parentheses) don't appear in the URL. So these two files both claim /:
app/
index.tsx -> /
(tabs)/
index.tsx -> also /
You get a conflict, and whichever screen wins, it's probably not the one you wanted as your entry point. Pick one owner for /:
app/
_layout.tsx
(tabs)/
_layout.tsx
index.tsx -> /
settings.tsx -> /settings
If you need a splash or redirect at the root, do it in the root _layout.tsx, not with a competing index.tsx.
3. Deep links that land with no back button
A user taps a link to /product/42. The product screen opens, but there's nothing under it in the stack, so the back button does nothing. That's because the stack was never told what its first screen is.
// app/(shop)/_layout.tsx
import { Stack } from 'expo-router';
export const unstable_settings = {
// Recent templates call this `anchor`. Check the docs for your SDK version.
initialRouteName: 'index',
};
export default function ShopLayout() {
return <Stack />;
}
Now a deep link into /product/42 renders the shop index underneath, and back behaves like users expect.
4. Treating dynamic params as always-a-string
[id].tsx gives you a param, but untyped params can be string | string[]. The classic bug is passing it straight to a fetch and getting 42,43 in your URL from a catch-all or a repeated query key.
// app/product/[id].tsx
import { useLocalSearchParams } from 'expo-router';
export default function ProductScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
if (!id || Array.isArray(id)) {
return null; // or render a not-found state
}
return <ProductDetail id={id} />;
}
Two habits that save time here:
- Prefer
useLocalSearchParamsinside stacks.useGlobalSearchParamsre-renders on every route change in the app, which you rarely want. - Navigate with the object form so params are explicit:
import { router } from 'expo-router';
router.push({ pathname: '/product/[id]', params: { id: product.id } });
5. Modals declared deep in the tree
A modal defined inside a tab's stack only presents over that tab. Want it available from anywhere? Declare it once, at the root.
// app/_layout.tsx
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="compose" options={{ presentation: 'modal' }} />
</Stack>
);
}
app/
_layout.tsx
compose.tsx <- modal, reachable from any tab
(tabs)/
_layout.tsx
index.tsx
Also: declare each screen once. Listing the same screen in two places in a layout is not allowed, so if its availability depends on state, wrap it conditionally instead of duplicating it.
6. No +not-found.tsx and no typed routes
Two small files that catch a whole class of bugs.
// 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: 'Not found' }} />
<Text>This screen doesn't exist.</Text>
<Link href="/">Go home</Link>
</View>
);
}
Then turn on typed routes so a typo in href fails at compile time instead of in production:
{
"expo": {
"experiments": {
"typedRoutes": true
}
}
}
After the next npx expo start, <Link href="/prodcut/42" /> becomes a TypeScript error.
Generated trees need the same review
These mistakes show up the same way whether the tree was hand-written or scaffolded. If you're generating Expo screens from a prompt or a screenshot with something like RapidNative, the output is real code you own, so run it through the same checklist before you build on top of it. It takes five minutes and saves a week of "why does back do nothing".
The checklist
[ ] app/ contains only screens, layouts, and special files (+not-found, +html)
[ ] Exactly one file resolves to each URL (check groups!)
[ ] Every nested stack sets an initial route for deep links
[ ] Dynamic params are typed and guarded against arrays
[ ] Global modals live in the root layout; each screen declared once
[ ] +not-found.tsx exists and typedRoutes is on
What's the weirdest Expo Router bug you've traced back to a file in the wrong folder? Drop it in the comments, I'm collecting them.
Top comments (0)