DEV Community

Famitha M A
Famitha M A

Posted on Originally published at fami-blog.hashnode.dev

Design Your Expo Router Tree Before You Write a Single Screen

Most Expo Router tutorials start with npx create-expo-app and a single index.tsx. That's fine for screen one. By screen thirty, the same project usually has three index.tsx files nobody can tell apart, a modal that remounts the whole tab bar, and a components/ folder that accidentally became a set of routes.

None of that is a code problem. It's a planning problem.

With file-based routing, the folder tree is the navigation design. Every folder you create is a decision about URLs, layouts, and what gets remounted when a user moves around. This post is a planning-first workflow: sketch the route tree as a sitemap, review it like a design artifact, and only then create files.

In Expo Router, mkdir is a product decision. Make it on paper first.

Why the tree deserves a design pass

Property What it means for planning
Every file in the routes directory is a route Helper files placed there become screens (or break the build)
_layout.tsx files wrap everything below them Folder depth decides which navigator, header, and providers a screen inherits
Route groups (name) organize without adding URL segments You can restructure layouts without changing deep links, if you plan for it

Moving a screen between folders can change its URL, its parent navigator, and its remount behavior in one move. That's a change you want to make on a whiteboard, not in a refactor PR.

Newer Expo templates place routes under src/app/ instead of app/. The rules are identical. Examples use app/ for brevity.

Step 1: Write the sitemap as URLs, not screens

List every destination as a URL a deep link could point at. Running example: a habit-tracking app.

/                     -> home feed (today's habits)
/habits               -> all habits
/habits/:id           -> habit detail
/habits/:id/edit      -> edit habit (modal)
/habits/new           -> create habit (modal)
/stats                -> weekly stats
/settings             -> settings
/settings/account     -> account details
/sign-in              -> sign in
/onboarding           -> first-run flow
Enter fullscreen mode Exit fullscreen mode

Three questions to answer now:

  • Which must be deep-linkable? A push notification or email link will point at /habits/:id, so that URL must stay stable.
  • Which are modals? new and edit present over the current context, not inside the tab navigator's stack.
  • Which are gated? sign-in and onboarding are only reachable in certain states.

If a screen can't be expressed as a URL, it's probably a component, a sheet, or a step inside another screen.

Step 2: Group by navigator, not by feature

The common mistake: grouping top-level folders by feature (habits/, stats/, settings/) and bolting a tab bar on top. Group by which navigator owns the screen.

app/
├── _layout.tsx              # Root Stack: owns modals + gating
├── +not-found.tsx
├── sign-in.tsx
├── onboarding.tsx
├── (tabs)/
│   ├── _layout.tsx          # Tabs navigator
│   ├── index.tsx            # /
│   ├── stats.tsx            # /stats
│   ├── habits/
│   │   ├── _layout.tsx      # Stack inside the Habits tab
│   │   ├── index.tsx        # /habits
│   │   └── [id].tsx         # /habits/:id
│   └── settings/
│       ├── _layout.tsx
│       ├── index.tsx        # /settings
│       └── account.tsx      # /settings/account
└── habits/
    ├── new.tsx              # /habits/new  (modal, root stack)
    └── [id]/
        └── edit.tsx         # /habits/:id/edit (modal, root stack)
Enter fullscreen mode Exit fullscreen mode

The (tabs) group adds no URL segment, so /stats stays /stats. The modals live outside the tabs group as root-stack siblings, so they present over the tab bar.

/habits and /habits/new share a URL prefix but live in different folders. URLs describe destinations; folders describe navigator ownership. Keep those separate and most "why does this screen look wrong" bugs disappear.

Step 3: Decide what each layout owns

One line per _layout.tsx before you write any. If you need two lines, you need two layouts.

Layout Owns
app/_layout.tsx Providers, fonts, auth gating, modal presentation
app/(tabs)/_layout.tsx Tab bar, tab icons, badge counts
app/(tabs)/habits/_layout.tsx Header styling for the habits stack
app/(tabs)/settings/_layout.tsx Header styling for settings

The matching root layout, with modals and protected routes:

// app/_layout.tsx
import { Stack } from 'expo-router';
import { useSession } from '@/lib/session';export default function RootLayout() {
  const { isSignedIn, hasOnboarded } = useSession();

  return (
    <Stack screenOptions={{ headerShown: false }}>
      <Stack.Protected guard={!isSignedIn}>
        <Stack.Screen name="sign-in" />
      </Stack.Protected>

      <Stack.Protected guard={isSignedIn && !hasOnboarded}>
        <Stack.Screen name="onboarding" />
      </Stack.Protected>

      <Stack.Protected guard={isSignedIn && hasOnboarded}>
        <Stack.Screen name="(tabs)" />
        <Stack.Screen name="habits/new" options={{ presentation: 'modal' }} />
        <Stack.Screen name="habits/[id]/edit" options={{ presentation: 'modal' }} />
      </Stack.Protected>
    </Stack>
  );
}
Enter fullscreen mode Exit fullscreen mode
  • Gating lives in one place. No redirect logic sprinkled across screens.
  • A screen belongs to one active group at a time. If you want the same screen in two guarded blocks, the sitemap needs another URL, not a clever workaround.

Step 4: Plan dynamic segments and their params

Params arrive as strings (or string arrays). Decide their shape up front and validate at the boundary.

// app/(tabs)/habits/[id].tsx
import { useLocalSearchParams, Redirect } from 'expo-router';
import { HabitDetail } from '@/features/habits/HabitDetail';export default function HabitScreen() {
  const { id } = useLocalSearchParams<{ id: string }>();

  // Deep links can carry anything. Validate before you fetch.
  if (!id || !/^[a-z0-9-]+$/i.test(id)) {
    return <Redirect href="/habits" />;
  }

  return <HabitDetail habitId={id} />;
}
Enter fullscreen mode Exit fullscreen mode

The route file is thin: read the param, validate, hand off to a feature component. That keeps the routes directory a map and nothing more.

When you're planning the screens themselves, this is a good time to prototype. RapidNative turns a prompt, sketch, or PRD into React Native and Expo screens, so you can paste your Step 1 sitemap in as context and get UI scaffolded around the structure you already decided on.

Step 5: Keep non-routes out of the routes directory

The routes directory holds screens, layouts, and special files like +not-found.tsx. Nothing else.

app/                # routes only
features/
  habits/
    HabitDetail.tsx
    HabitCard.tsx
    useHabit.ts
  stats/
components/         # shared UI primitives
lib/                # session, api client, storage
Enter fullscreen mode Exit fullscreen mode
  • No accidental routes. A HabitCard.tsx in app/(tabs)/habits/ becomes a navigable screen.
  • Cheap restructures. Moving habits out of tabs means moving a few thin route files; feature code stays put.
  • Readable reviews. A diff in app/ is a navigation change. A diff in features/ is UI or logic.

Step 6: Review the tree before you commit

Check Question to ask
Index ambiguity Multiple index.tsx files? Is each obviously tied to its folder?
Modal placement Are all modals root-stack siblings, not nested in a tab?
Remount risk If this screen moves groups later, does its layout ancestry change and remount state?
Deep link stability Are the URLs a notification or email points at unlikely to change?
Gating Is every protected screen covered by exactly one guard in one layout?
Not found Is there a +not-found.tsx so broken links land somewhere useful?

Then turn on typed routes so the compiler enforces the tree. A typo in an href becomes a type error instead of a blank screen in production. Check the Expo docs for your SDK version to see whether it's on by default or needs a config flag.

import { Link } from 'expo-router';

// Typed: autocompletes valid paths, flags invalid ones.
<Link href={{ pathname: '/habits/[id]', params: { id: habit.id } }}>
  {habit.name}
</Link>
Enter fullscreen mode Exit fullscreen mode

Step 7: Test deep links against the sitemap

Your Step 1 list doubles as a test plan. Open every URL on a device or simulator and confirm the right screen and navigator appear:

# iOS simulator
npx uri-scheme open "myapp://habits/abc-123" --ios

# Android emulator
npx uri-scheme open "myapp://habits/abc-123/edit" --android
Enter fullscreen mode Exit fullscreen mode

Watch for two things: modals presenting as modals when opened cold from a link, and gated URLs redirecting cleanly when signed out.

Once the tree is stable, the last hurdle is store review. If you'd rather not wrangle signing, listings, and submission yourself, RapidNative Deploy handles the App Store and Play Store submission side.

A planning template you can copy

Paste into your PRD or README before creating the routes directory:

## Route plan

### Sitemap (URLs)
- / :
- /... :

### Navigators
- Root stack owns: (modals, gating, providers)
- Tabs: (list tabs)
- Nested stacks: (which tabs have their own stack)

### Modals (root stack siblings)
-

### Gated groups
- Signed out:
- Signed in, not onboarded:
- Signed in:

### Deep links that must stay stable
-
Enter fullscreen mode Exit fullscreen mode

If your team works from a PRD, hand this section to an AI builder too. In RapidNative, including the route plan next to the feature description gives generated screens a structure to fit into.

Wrapping up

  1. Write the sitemap as URLs.
  2. Group folders by navigator, not by feature.
  3. Give every layout one job.
  4. Keep route files thin and validate params at the boundary.
  5. Keep non-route code out of the routes directory.
  6. Review the tree like a design artifact, then lock it in with typed routes.
  7. Test every URL in the sitemap as a deep link.

What does your route tree look like at screen thirty? Share the structure in the comments.

Top comments (0)