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,
mkdiris 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 ofapp/. The rules are identical. Examples useapp/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
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?
newandeditpresent over the current context, not inside the tab navigator's stack. -
Which are gated?
sign-inandonboardingare 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)
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>
);
}
- 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} />;
}
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
-
No accidental routes. A
HabitCard.tsxinapp/(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 infeatures/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>
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
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
-
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
- Write the sitemap as URLs.
- Group folders by navigator, not by feature.
- Give every layout one job.
- Keep route files thin and validate params at the boundary.
- Keep non-route code out of the routes directory.
- Review the tree like a design artifact, then lock it in with typed routes.
- 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)