Expo Router folder structure: layouts, groups, guards
How the app directory becomes a navigator tree, what belongs outside it, and the structural decisions that get expensive once an Expo app passes twenty screens.
The file tree is the navigation config
In an Expo Router project there is no central navigator file to read. The route tree is the folder tree: files under app/ become screens, and the _layout.tsx files beside them decide how those screens relate. That makes the structure unusually consequential. In a React Navigation app a bad folder layout is untidy; here it is the navigation.
The decision worth making early is the one the framework will not make for you: which directories are routes, and which are everything else. The app directory is a public surface — every file in it is reachable. Business logic, design system pieces, data access, and state belong outside it.
The free Next.js structure painter sketches route trees the same way for the web, and the Nodlume workspace plans Expo projects against the same canvas, because Expo Router's (group) and [param] segments are the App Router's.
What Expo Router file-based routing replaces
Expo Router is file-based routing for React Native and web: routes are defined by files and sub-directories inside app/, rather than by a route map you maintain by hand.
app/
index.tsx → /
settings.tsx → /settings
products/
index.tsx → /products
[productId].tsx → /products/42
The convention set is small, and worth learning as a whole rather than a piece at a time:
index.tsx— the default route for its directory._layout.tsx— the navigator for its directory. Never a screen itself.(group)— a route group: organises files without appearing in the URL.[param]— a dynamic segment, read withuseLocalSearchParams.+not-found,+html,+native-intent,+middleware— the special routes, prefixed with+.
What this replaces is the import-and-register step. There is no file that names every screen, which means there is also no file that drifts out of sync with the screens that exist. The trade is that a mistake in naming is a routing bug rather than a compile error, and that renaming a file changes a URL.
_layout.tsx is a navigation boundary
Each _layout.tsx wraps the routes in its own directory and nothing above it. The root app/_layout.tsx renders before everything, so it is where providers, session restoration, and the splash screen belong. Nested layouts make local decisions.
A root layout stays small:
import { Stack } from "expo-router"
export default function RootLayout() {
return <Stack screenOptions={{ headerShown: false }} />
}
A section layout owns its own navigation behaviour:
// app/products/_layout.tsx
import { Stack } from "expo-router"
export default function ProductsLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: "Products" }} />
<Stack.Screen name="[productId]" options={{ title: "Product details" }} />
</Stack>
)
}
Read every _layout.tsx as a boundary: it is the only place a folder's screens can be given shared headers, shared providers, shared options, or a different navigator. A folder with no layout file inherits its parent's, which is usually what you want — add one when the section needs to behave differently, not by default.
Route groups organise without changing the URL
A directory in parentheses groups routes without contributing a URL segment. app/(tabs)/profile.tsx serves /profile, not /(tabs)/profile.
This is what lets navigation structure and URL structure disagree on purpose, which they usually should. Users experience /profile; the codebase needs to know that profile is a tab and sign-in is not.
app/
_layout.tsx
(auth)/
_layout.tsx
sign-in.tsx
sign-up.tsx
(app)/
_layout.tsx
index.tsx
account.tsx
Group names are read by developers only, so name them for the flow they represent — (auth), (onboarding), (admin) — rather than for position. (main) tells the next person nothing.
The structural payoff is that each group gets its own layout file, so no single navigator accumulates rules for unrelated parts of the app.
Choosing a navigator per layout file
The navigator is a per-section decision, made in that section's layout, and it should follow how people actually move:
- Stack — forward-and-back flows with a sense of depth: lists into details, multi-step forms.
- Tabs — a handful of peer destinations that users switch between constantly.
- Drawer — many destinations that matter but are not needed continuously, where a tab bar would overflow.
Tabs almost always belong in a group, so the bar's existence does not leak into every URL:
// app/(tabs)/_layout.tsx
import { Tabs } from "expo-router"
export default function TabsLayout() {
return (
<Tabs screenOptions={{ headerShown: false }}>
<Tabs.Screen name="index" options={{ title: "Home" }} />
<Tabs.Screen name="search" options={{ title: "Search" }} />
<Tabs.Screen name="profile" options={{ title: "Profile" }} />
</Tabs>
)
}
Detail flows nest a stack inside a tab, so back navigation stays within that tab rather than unwinding the whole app:
app/
(tabs)/
_layout.tsx
index.tsx
products/
_layout.tsx
index.tsx
[productId].tsx
One version note that invalidates a lot of older tutorials: in SDK 56 and later, Expo Router no longer supports importing from @react-navigation/* packages in application code — those imports repoint to the matching expo-router entry points, and Expo ships a codemod for the migration. Copying a drawer setup from a 2024 blog post is the most common way to hit this.
Dynamic routes and route parameters
Square brackets make one screen serve many paths. Use them when the layout is fixed and only the data changes — a product, an order, an article, a user.
// app/products/[productId].tsx
import { Text, View } from "react-native"
import { useLocalSearchParams } from "expo-router"
export default function ProductScreen() {
const { productId } = useLocalSearchParams<{ productId: string }>()
return (
<View>
<Text>Product: {productId}</Text>
</View>
)
}
Link to them by pathname and params rather than by building the string, so the parameter name stays checkable:
import { Link } from "expo-router"
export function ProductCard({ id, name }: { id: string; name: string }) {
return (
<Link href={{ pathname: "/products/[productId]", params: { productId: id } }}>
{name}
</Link>
)
}
Name the segment for what it holds. [productId] survives being read in a file that also handles [orderId]; [id] does not.
Dynamic routes are also the deep-linking surface: /products/42 opens that screen directly. Expo Router generates a /_sitemap route to make the available routes inspectable during development; it defaults to on and is turned off through the expo-router config plugin.
{
"expo": {
"plugins": [["expo-router", { "sitemap": false }]]
}
}
Protected routes are a guard prop, not an if
The pattern most guides still show — read the session in the root layout, return one tree or the other — is the SDK 52 approach. Since SDK 53 the framework has a first-class construct: Stack.Protected with a guard prop.
// app/_layout.tsx
import { Stack } from "expo-router"
import { useSession } from "@/features/auth/use-session"
export default function RootLayout() {
const { session, isLoading } = useSession()
if (isLoading) return null
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Protected guard={!!session}>
<Stack.Screen name="(app)" />
</Stack.Protected>
<Stack.Protected guard={!session}>
<Stack.Screen name="(auth)" />
</Stack.Protected>
</Stack>
)
}
The difference is not stylistic. A guarded screen redirects when a user navigates to it and when it becomes protected while already active — the session expiring mid-session is handled by the same declaration, which a conditional tree does not do.
Two things it still does not do for you. Keep the loading state real: returning null while the session restores is fine only if the splash screen is still up, or users see a flash of the wrong tree. And treat protection as behaviour rather than concealment — a deep link into a private screen should route through sign-in and, where it makes sense, resume the intended destination afterwards. Nodlume's role matrix expresses this as access rules per route, which is the same decision made before the code exists.
Where shared components, hooks, and stores belong
Outside app/. A file placed in the route tree is a route, and a component that accidentally becomes one is a URL nobody meant to publish.
src/
app/ # routes and layouts only
(auth)/
(tabs)/
features/ # domain logic, colocated
auth/
use-session.ts
components/
products/
api.ts
types.ts
components/
components/
ui/ # design system primitives
shared/ # cross-feature composites
hooks/
stores/
lib/
The rule is colocation with a boundary. A filter used only by product screens lives in features/products/components; a button used everywhere lives in components/ui. Feature folders can then be moved, split, or deleted without touching the navigation tree — which is the actual test of whether the boundary is real.
Stores deserve a deliberate home rather than an incidental one. Cross-screen state in stores/, screen-local state in the screen, server data in a query cache rather than a store at all. Zustand stores in a planned project are declared alongside the screens that read them for the same reason.
A structure that survives twenty screens
Past twenty screens the goal stops being a short tree and becomes a predictable one. What you want is that a developer who has never opened the project can guess where a screen lives, and be right.
- Keep
app/_layout.tsxto providers and top-level structure. - Give every major flow a route group.
- Add a nested layout only when a folder needs different navigation behaviour.
- Keep non-route files out of
app/without exception. - Give a feature its own folder once it has several screens, its own API calls, and its own components — not before.
- Name dynamic segments for their contents.
- Check
/_sitemapand test deep links before shipping a routing change.
The trade-off worth stating: this structure front-loads decisions. Route groups, layout boundaries, and the feature/shared split are cheap to choose on day one and expensive to change once forty files import across them. That is the same reason route trees are worth sketching before implementation on the web, and why navigation architecture is a design activity rather than a coding one.
If the app is also shipping to the stores, the other early-and-cheap decision is the permission set for both platforms — declared in app.json, and just as awkward to retrofit.
Frequently asked questions
Does every folder in Expo Router need a _layout.tsx file?
No. A folder needs one only when its routes require their own navigator, shared headers, shared providers, or guard rules. Without one, the folder's screens use the nearest layout above them, which is usually correct. Adding layout files reflexively produces nested navigators nobody asked for and back behaviour nobody expects.
How do route groups differ from normal folders?
A normal folder contributes a segment to the URL; a group in parentheses does not. products/index.tsx serves /products, while (tabs)/index.tsx serves /. Groups exist so navigation structure and URL structure can differ — which is what lets you give a section its own layout without exposing an implementation detail in every link.
Where should components that are not screens live?
Outside the app directory. Anything in app/ is routable, so a stray component there becomes a reachable URL. Put design system primitives in components/ui, cross-feature pieces in components/shared, and feature-specific components inside that feature's folder.
How do I handle authentication with Expo Router?
Separate signed-out and signed-in routes into groups, keep session state in a hook or store outside app/, and wrap the screens in Stack.Protected with a guard prop. Hold the splash screen while the session restores, and make sure a deep link into a protected screen routes through sign-in rather than failing silently.
Can I mix Expo Router with React Navigation?
Expo Router is built on React Navigation and re-exports the navigators you need. From SDK 56 onwards, application code may no longer import from @react-navigation/* directly — those imports move to the matching expo-router entry points, and Expo provides a codemod. Follow the current Expo guidance rather than older React Navigation examples, which is where most upgrade breakage comes from.
Target-specific scaffolding
Continue this learning path
Permissions, manifests, capabilities, agent projects and MCP servers for browser extensions, desktop, mobile, and backend agents.
Design your application
Design Web, Desktop, Mobile, Terminal, or Extension projects with a target-aware canvas and exporter.
Open projects