in Expo Router, your folder structure is your navigation. _layout.tsx files decide how screens are shown (stack, tabs, modal), route groups like (tabs) organize screens without changing URLs, [id].tsx handles dynamic routes, and every screen is deep-linkable automatically. The basics take ten minutes to learn. This guide covers the patterns you actually need once the app has auth, tabs, modals and links coming in from outside.
Most file-based routing tutorials stop at "create app/index.tsx and app/about.tsx". Real apps need more: a tab bar that disappears on detail screens, a login flow nobody can skip, modals that work with the back gesture and push notifications that open the right screen. All of that is solvable with folder structure, once you know the patterns.
The mental model: files are routes, layouts are navigators
Two rules explain almost everything:
- Every file in
app/is a route.app/settings.tsxis/settings.app/projects/[id].tsxis/projects/42. - Every
_layout.tsxwraps the routes next to it and decides how they're presented: as a stack, as tabs, or as anything custom.
A few special names complete the picture:
-
index.tsx: the default route for its folder (/or/projects). -
[param].tsx: a dynamic segment, read withuseLocalSearchParams(). -
[...rest].tsx: a catch-all for any depth. -
(group)/: a route group. It organizes files and can have its own layout, but doesn't appear in the URL. -
+not-found.tsx: what to show for unmatched routes.
Pattern 1: Tabs inside a stack (the structure most apps need)
The classic mobile layout: a bottom tab bar on the main screens, and detail screens that slide in over the tabs, hiding the tab bar. The trick is that the tabs live inside a stack, not the other way round.
app/ ├── _layout.tsx # root Stack ├── (tabs)/ │ ├── _layout.tsx # Tabs navigator │ ├── index.tsx # / (Home tab) │ ├── search.tsx # /search (Search tab) │ └── settings.tsx # /settings (Settings tab) ├── projects/ │ └── [id].tsx # /projects/42 (pushed over the tabs) └── +not-found.tsx // app/_layout.tsx import { Stack } from 'expo-router'; export default function RootLayout() { return ( <Stack> <Stack.Screen name="(tabs)" options={tabsOptions} /> <Stack.Screen name="projects/[id]" options={detailOptions} /> </Stack> ); } const tabsOptions = { headerShown: false }; const detailOptions = { title: 'Project' }; // app/(tabs)/_layout.tsx import { Tabs } from 'expo-router'; export default function TabsLayout() { return ( <Tabs> <Tabs.Screen name="index" options={homeOptions} /> <Tabs.Screen name="search" options={searchOptions} /> <Tabs.Screen name="settings" options={settingsOptions} /> </Tabs> ); } const homeOptions = { title: 'Home' }; const searchOptions = { title: 'Search' }; const settingsOptions = { title: 'Settings' }; Navigating to /projects/42 from any tab pushes the detail screen onto the root stack, so it covers the tab bar and gets a native back gesture. If you put projects/[id].tsx inside (tabs), it would become a tab (or render under the tab bar), which is rarely what you want.
Defining screen options as constants outside the JSX isn't required, but it keeps big layouts readable, and it's a habit that saves you from re-creating option objects on every render.
Pattern 2: Modals
A modal is just a stack screen with a different presentation:
app/ ├── _layout.tsx ├── (tabs)/... └── new-project.tsx # /new-project, shown as a modal // app/_layout.tsx (add to the root Stack) <Stack.Screen name="new-project" options={modalOptions} /> const modalOptions = { presentation: 'modal', title: 'New project' } as const; Open it with router.push('/new-project') and close it with router.back() (or router.dismiss()). On iOS you get the native sheet with swipe-to-dismiss for free. Because it lives on the root stack, it opens over the tabs from anywhere in the app.
Pattern 3: An auth gate nobody can skip
You want signed-out users to see only the auth screens, and signed-in users to never see them. Put each side in its own group:
app/ ├── _layout.tsx ├── (auth)/ │ ├── _layout.tsx │ ├── sign-in.tsx │ └── sign-up.tsx └── (app)/ ├── _layout.tsx └── (tabs)/... Recent versions of Expo Router include protected routes: wrap screens in Stack.Protected with a guard, and the router won't let anyone navigate to them while the guard is false, including through deep links:
// app/_layout.tsx import { Stack } from 'expo-router'; import { useSession } from '@/lib/auth'; export default function RootLayout() { const { session, isLoading } = useSession(); if (isLoading) return null; // or keep the splash screen visible return ( <Stack screenOptions={hidden}> <Stack.Protected guard={!!session}> <Stack.Screen name="(app)" /> </Stack.Protected> <Stack.Protected guard={!session}> <Stack.Screen name="(auth)" /> </Stack.Protected> </Stack> ); } const hidden = { headerShown: false }; On older versions without Stack.Protected, the same idea works with a redirect in the protected group's layout:
// app/(app)/_layout.tsx import { Redirect, Stack } from 'expo-router'; import { useSession } from '@/lib/auth'; export default function AppLayout() { const { session, isLoading } = useSession(); if (isLoading) return null; if (!session) return <Redirect href="/sign-in" />; return <Stack />; } Either way, the check lives in one layout, not in every screen. That's the main win: you can't forget to protect a new screen, because it inherits its folder's rules.
Pattern 4: Dynamic routes and params done right
// app/projects/[id].tsx import { useLocalSearchParams } from 'expo-router'; export default function ProjectScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const project = useProject(id); // your data hook // ... } Three things to know:
- Params are always strings. Parse numbers and booleans yourself, and validate them: a deep link can contain anything.
- Use
useLocalSearchParams, notuseGlobalSearchParams, inside screens. The local version only updates when this screen's params change, so screens further down the stack don't re-render every time you navigate. - Pass IDs, not objects. It's tempting to pass a whole project through params, but params are serialized into the URL. Pass the ID and fetch (or read from your cache) on the screen. This is also what makes the screen work when it's opened from a deep link.
To build links with params, prefer the object form, which handles encoding for you:
import { Link } from 'expo-router'; const href = { pathname: '/projects/[id]', params: { id: project.id } } as const; <Link href={href}>Open project</Link> Pattern 5: Typed routes
Typos in route strings fail at runtime. Expo Router can generate types for every route in app/ so they fail at compile time instead. Enable it in your app config:
{ "expo": { "experiments": { "typedRoutes": true } } } Run the dev server once to generate the types. After that, router.push('/projcts/42') is a TypeScript error, and editor autocomplete lists your real routes. Depending on your SDK version this may already be enabled by default; check your template.
Pattern 6: The same screen in more than one tab
Instagram-style apps open a profile from the Home tab and the Search tab, and in both cases the profile should stay inside that tab's stack. Expo Router handles this with shared routes, using an array-style group name:
app/(tabs)/ ├── _layout.tsx ├── (home,search)/ │ ├── _layout.tsx # a Stack, used by both tabs │ ├── home.tsx │ ├── search.tsx │ └── users/[id].tsx # one file, available in both tabs └── settings.tsx The (home,search) group creates two copies of the stack, one per tab, from the same files. Use useSegments() if a screen needs to know which tab it's in.
Pattern 7: Deep links and notification taps for free
Every route is a deep link automatically. With "scheme": "myapp" in your app config, myapp://projects/42 opens the project screen with the right params, and the protected route rules from Pattern 3 still apply.
That makes push notifications simple: put the route path in the notification's data and navigate to it when the user taps:
import { useEffect } from 'react'; import * as Notifications from 'expo-notifications'; import { router } from 'expo-router'; export function useNotificationRouting() { useEffect(() => { const sub = Notifications.addNotificationResponseReceivedListener((response) => { const url = response.notification.request.content.data?.url; if (typeof url === 'string') router.push(url); }); return () => sub.remove(); }, []); } Also handle the case where the app was closed when the notification was tapped, using Notifications.getLastNotificationResponseAsync() on startup. For links from your website (universal links on iOS, app links on Android), you configure your domain once and the same routes just work.
Common mistakes
- Putting detail screens inside
(tabs)when they should cover the tab bar. Detail screens usually belong on the root stack. - Checking auth in every screen instead of once in a layout.
- Passing whole objects as params. Pass IDs; they survive deep links and app restarts.
- Forgetting that groups don't change URLs.
app/(auth)/sign-in.tsxis/sign-in, not/auth/sign-in, so two groups can't both containindex.tsxat the same level without a conflict. - Using
router.pushfor redirects. After signing in, userouter.replaceso the back gesture doesn't return to the sign-in screen.
FAQ
Is Expo Router only for Expo apps? It's built on React Navigation and designed for Expo projects. Most new React Native apps start with Expo, so in practice it's the default router today.
Can I still use React Navigation APIs? Yes. Expo Router uses React Navigation under the hood, so navigator options, themes and many hooks carry over.
Does file-based routing work on the web too? Yes. The same app/ folder produces real URLs on the web, which is one of its biggest advantages for apps that ship to web and mobile.
How do I show a screen without a tab bar or header? Move it to the root stack (no tab bar) and set headerShown: false in its options (no header).
Conclusion
File-based routing turns navigation from a config file you maintain into a folder structure you can read at a glance. The patterns that matter in real apps are few: tabs inside a stack, modals on the root stack, an auth gate in one layout, IDs in params, typed routes, shared routes for multi-tab screens, and deep links that come for free.
If you'd like to start from a project that's already structured this way, RapidNative generates React Native and Expo apps with Expo Router, which you can export as a standard project and keep building on.
What's the navigation pattern that took you longest to get right? Share it in the comments.
