DEV Community

Famitha M A
Famitha M A

Posted on

6 Expo Router file-structure mistakes I keep finding in code reviews

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
Enter fullscreen mode Exit fullscreen mode

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 /
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 />;
}
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

Two habits that save time here:

  • Prefer useLocalSearchParams inside stacks. useGlobalSearchParams re-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 } });
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
app/
  _layout.tsx
  compose.tsx        <- modal, reachable from any tab
  (tabs)/
    _layout.tsx
    index.tsx
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Then turn on typed routes so a typo in href fails at compile time instead of in production:

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

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
Enter fullscreen mode Exit fullscreen mode

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)