Dev.to WebDev 🛠 Dev 👁 1 📖 4 min read

File-Based Routing: Your Folder Structure Is Your Navigation

TL;DR File-based routing = the file path is the route. No routes.js, no navigator registration. Expo Router: every file in app/ is a route. Next.js App Router: only page.tsx makes a route; folders are the segments.

TL;DR

  • File-based routing = the file path is the route. No routes.js, no navigator registration.
  • Expo Router: every file in app/ is a route. Next.js App Router: only page.tsx makes a route; folders are the segments.
  • Six conventions cover ~everything: index, [param], [...catchAll], _layout, (group), +not-found.
  • Biggest trap: renaming a file changes its URL and silently breaks deep links. Treat it like an API change.
  • Auth guards still need code, usually a <Redirect /> in a layout.

I used to have a ~300-line AppNavigator.tsx in every project that nobody wanted to touch. Adding a screen meant: create the component → register it → type its params. Skip step 2, crash. Skip step 3, any everywhere.

File-based routing deletes that file. Here's how it works, mostly in code.

Files → routes

Expo Router: every file is a route:

app/index.tsx            →  /
app/about.tsx            →  /about
app/blog/[slug].tsx      →  /blog/any-post-name
app/settings/profile.tsx →  /settings/profile

Next.js App Router: folders are segments, page.tsx is the route:

app/page.tsx                  →  /
app/about/page.tsx            →  /about
app/blog/[slug]/page.tsx      →  /blog/any-post-name
app/settings/profile/page.tsx →  /settings/profile

Same idea, different rule for what counts as a route. Remember that, because it matters later.

The 6 conventions

1. Index routes

app/blog/index.tsx (Expo) or app/blog/page.tsx (Next) → /blog.

2. Dynamic segments

Expo, via a hook:

// app/user/[id].tsx
import { Text } from 'react-native';
import { useLocalSearchParams } from 'expo-router';

export default function User() {
  const { id } = useLocalSearchParams<{ id: string }>();
  return <Text>User {id}</Text>;
}

Next.js 15+, via props, where params is now a Promise:

// app/user/[id]/page.tsx
export default async function User({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  return <h1>User {id}</h1>;
}

3. Catch-all

// app/docs/[...slug].tsx  →  /docs/a/b/c
const { slug } = useLocalSearchParams<{ slug: string[] }>();
// slug === ['a', 'b', 'c']

4. Layouts

A layout wraps every route in its folder and stays mounted. In React Native, this is where you pick the navigator:

// app/_layout.tsx
import { Stack } from 'expo-router';

export default function RootLayout() {
  return <Stack screenOptions={{ headerShown: false }} />;
}
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import Ionicons from '@expo/vector-icons/Ionicons';

export default function TabsLayout() {
  return (
    <Tabs>
      <Tabs.Screen
        name="index"
        options={{
          title: 'Home',
          tabBarIcon: ({ color }) => <Ionicons name="home" size={22} color={color} />,
        }}
      />
      <Tabs.Screen name="search" options={{ title: 'Search' }} />
      <Tabs.Screen name="profile" options={{ title: 'Profile' }} />
    </Tabs>
  );
}

Navigators are folders now. That's the whole mental shift.

5. Route groups

(parens) = organise files without adding a URL segment. app/(auth)/login.tsx → /login. Use groups to give screens different layouts (tabs vs. plain stack).

6. Not-found

+not-found.tsx (Expo) / not-found.tsx (Next). Add it on day one; a blank screen on a bad deep link looks like a crash.

A real structure

app/
├── _layout.tsx          # root Stack
├── (auth)/
│   ├── _layout.tsx      # plain Stack, no tabs
│   ├── login.tsx        # /login
│   └── signup.tsx       # /signup
├── (tabs)/
│   ├── _layout.tsx      # Tabs
│   ├── index.tsx        # /
│   ├── search.tsx       # /search
│   └── profile.tsx      # /profile
├── post/
│   └── [id].tsx         # /post/42, pushed over tabs
└── +not-found.tsx

You can read the entire app's navigation from ls -R app/. Try that with a hand-rolled navigator.

Navigating

import { Link, router } from 'expo-router';

<Link href="/post/42">Open post</Link>

// with params object
<Link href={{ pathname: '/post/[id]', params: { id: '42' } }}>Open post</Link>

router.push('/settings/profile');
router.replace('/login');   // no back entry
router.back();

Turn on typed routes and typos become compile errors:

// app.json
{
  "expo": {
    "experiments": { "typedRoutes": true }
  }
}
<Link href="/psot/42" />
//          ^^^^^^^^ Type error: not a valid route

Where it bites

Renaming = breaking change

git mv app/settings/profile.tsx app/account/profile.tsx changes the URL. Push notification payloads, emails, bookmarks and SEO all still point at the old path. Leave a redirect behind:

// app/settings/profile.tsx  (keep this file after the move)
import { Redirect } from 'expo-router';
export default function Moved() {
  return <Redirect href="/account/profile" />;
}

In Next.js, use redirects() in next.config.js instead.

Non-route files in app/

  • Expo Router: everything in app/ is a route. Drop Button.tsx in there and you've shipped a screen called /Button. Keep shared code in components/, lib/, hooks/.
  • Next.js: only page.tsx / route.ts etc. are routes, so colocating components is safe. Still, many teams keep them out for clarity.

Deep nesting

Three levels of groups, each with its own _layout.tsx, and nobody can tell which layout wraps which screen. If you need a diagram for your folder tree, flatten it.

Auth guards are still code

The folder tree can't express "signed-in users only." Do it in the layout:

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

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

  if (isLoading) return null;
  if (!user) return <Redirect href="/login" />;

  return <Tabs />;
}

Newer Expo Router versions also have Stack.Protected for declarative guards. In Next.js this usually goes in middleware.ts or a server-side check in layout.tsx.

Should you switch?

  • New project: yes. Both Expo and Next.js default to it; fighting the default costs more than it saves.
  • Existing React Navigation app: migrate one flow at a time (onboarding is easiest) and keep a redirect for every URL you move.

If you want to poke at a working tree before designing your own, RapidNative generates Expo apps with the app/ folder, layouts and groups already wired up, and you can read and edit them like normal code.

Wrap-up

Six conventions, keep non-route code out of app/, and treat every rename as a URL change. That's it.

How is your app/ folder organised? Drop your tree in the comments. I'm curious how people use route groups in production.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.