Lesson 4 — combining pathless layouts, groups, and dynamic segments into one tree
You've learned every routing primitive in isolation. Now we compose them into the structure the whole mission has been building toward: a 3-level nested mobile app — app shell → bottom tabs → detail screen. This is the last open mission item. Get this right and TanStack routing clicks into place.
From MISSION.md: build a 3-level deeply nested layout (app → tabs → detail) without trial-and-error.
Concretely, a mobile feed app needs:
/feed/42 — that open on top of the tab bar, captured via $param.Three layers, two of them URL-invisible. This is exactly what pathless layouts and groups were invented for.
Before touching files, classify each screen by which primitive it needs:
| Screen | URL? | Primitive |
|---|---|---|
| App shell (status bar, providers) | none | _app pathless layout |
| Bottom tab bar | none | (tabs) pathless group |
| Home tab | /home | index route |
| Feed list | /feed | index route |
| Post detail | /feed/42 | $postId dynamic |
| Profile tab | /profile | index route |
$param. If no → pathless layout (_name) or group ((name)). Get the classification right and the file tree writes itself.
_app)Start with the outermost pathless layer — the shell that wraps everything. A folder prefixed with _ is a pathless layout: its route.tsx renders, but the folder name never enters the URL.
// routes/_app/route.tsx
export const Route = createFileRoute('/_app')({
component: AppShell,
})
function AppShell() {
return (
<div className="phone-frame">
<StatusBar />
<Outlet /> {/* everything inside _app renders here */}
</div>
)
}
Because _app is pathless, <Outlet/> here renders whatever route is matched underneath it — without /app ever appearing in the URL.
(tabs) Group)Inside _app, nest a pathless group. A group is a pathless folder whose name is wrapped in parentheses — purely organizational, but its route.tsx still renders as a layout. Perfect for a tab bar that wraps the tab screens:
// routes/_app/(tabs)/route.tsx
export const Route = createFileRoute('/_app/(tabs)')({
component: TabLayout,
})
function TabLayout() {
return (
<>
<Outlet /> {/* the active tab screen */}
<BottomNav /> {/* persistent tab bar */}
</>
)
}
_name (pathless) says "this is structural chrome"; (name) (group) says "these routes belong together, organize them". For tabs you can use either — groups signal "tab screens live here" to future readers. Recall Lesson 2.
$postId)Now drop the actual tab screens inside the group. The Feed tab is special — it has both a list and a detail. The list is an index.tsx (exact match on /feed); the detail uses a $postId dynamic segment:
Visiting /feed/42 resolves $postId to "42" — read it with Route.useParams(), exactly like Lesson 3. No new concept; just nesting.
Notice search/ sits next to _app, not inside it — a full-screen route with no tab bar. That's the payoff of pathless layers: routes outside the group escape the chrome for free.
Four components nest like Russian dolls. Each <Outlet/> hands off to the next layer down, until the leaf route renders your actual UI.
This is the moment of truth — read a URL and predict the rendered component stack:
| URL | Component stack (outer → inner) |
|---|---|
/home |
__root → _app → (tabs) → home/index |
/feed/42 |
__root → _app → (tabs) → feed/$postId/index (postId="42") |
/search |
__root → search/index (no shell, no tabs) |
_app, (tabs)) wrap their descendants only. /search skips them because it is a sibling of _app, not a child. URL position in the tree ≡ which layouts wrap you.
Extend the app with a new requirement: a Profile detail screen at /profile/u/ana (note the literal /u/ segment), still inside the tab bar. Plus a full-screen settings page with no tabs.
Answer mentally first: which folders go inside (tabs)? Which sit next to _app? Where does the literal u segment live? Then reveal.
Why this works:
profile/u/$username/ mixes a literal segment u with a dynamic $username — order is preserved exactly in the URL.settings/ is a child of routes/, not _app/, so it renders with no shell and no tab bar./profile/u/ana because it lives inside (tabs) — opening a profile keeps the bottom nav visible._app/route.tsx add to the URL?_ prefix marks a pathless layout. Its route.tsx renders as a wrapper, but the folder name never appears in the URL.routes/_app/(tabs)/route.tsx is the tab layout — its <Outlet/> renders the active tab screen, with <BottomNav/> beside it./feed/42 matches which file?index.tsx matches /feed exactly; the $postId/index.tsx matches /feed/<anything>. 42 becomes params.postId._app and (tabs) both wrap feed/$postId/index.tsx. Two pathless layouts, both invisible in the URL.(group) instead of a normal folder for tabs?/tabs/._app/route.tsx's <Outlet/> render into?<Outlet/> renders its deepest matched descendant — here the tab layout, which in turn renders the active tab screen./feed list screen maps to which file?route.tsx is a layout; index.tsx is the exact-match leaf. /feed hits index.tsx, /feed/42 hits $postId/index.tsx./search shows no tab bar. Why?search/ is a sibling of _app, not a child. Pathless layouts only wrap their descendants — so /search escapes both shell and tabs.settings/index.tsx inside (tabs)/. It inherits the tab bar automatically and resolves to /settings._app (pathless), (tabs) (group), and $postId (dynamic) — all three primitives from Lessons 1–3 composed into one mobile tree. Mission complete./search escapes the shell, how to add a modal overlay route, or how to wire real navigation between /feed/42 and /profile/u/ana in your own app.
Primary source: TanStack Router — Route Trees (how the flat file tree builds the nested route tree). Read it to see the matching algorithm that powers the walkthrough table above.