Capstone: A Mobile Route Tree

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.

The Mission, Revisited

From MISSION.md: build a 3-level deeply nested layout (app → tabs → detail) without trial-and-error.

Concretely, a mobile feed app needs:

  1. An app shell — status bar, safe-area, providers — present on every screen, but not part of any URL.
  2. A bottom tab bar — Home, Feed, Profile — persistent across those screens, also invisible in the URL.
  3. Detail screens/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.

Decompose the Screens

Before touching files, classify each screen by which primitive it needs:

ScreenURL?Primitive
App shell (status bar, providers)none_app pathless layout
Bottom tab barnone(tabs) pathless group
Home tab/homeindex route
Feed list/feedindex route
Post detail/feed/42$postId dynamic
Profile tab/profileindex route
🧠 The whole skill Mobile routing is mostly classification. Decide for each screen: does it own a URL segment? If yes → normal route or $param. If no → pathless layout (_name) or group ((name)). Get the classification right and the file tree writes itself.

Level 1: The App Shell (_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 → shell, no URL segment
// 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.

Level 2: The Tab Bar ((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/ ├── route.tsx └── (tabs)/ └── route.tsx → bottom tab bar
// 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 */}
    </>
  )
}
Group vs pathless layout Both are URL-invisible. The difference is intent: _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.

Level 3: Detail Screen ($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:

(tabs)/ ├── home/ │ └── index.tsx → /home ├── feed/ │ ├── index.tsx → /feed (list) │ └── $postId/ │ └── index.tsx → /feed/42 (detail) └── profile/ └── index.tsx → /profile

Visiting /feed/42 resolves $postId to "42" — read it with Route.useParams(), exactly like Lesson 3. No new concept; just nesting.

The Full Tree

routes/ ├── __root.tsx → html shell, global providers ├── _app/ ← L1: mobile shell (pathless) │ ├── route.tsx │ └── (tabs)/ ← L2: tab bar (pathless group) │ ├── route.tsx │ ├── home/ │ │ └── index.tsx → /home │ ├── feed/ │ │ ├── index.tsx → /feed (list) │ │ └── $postId/ │ │ └── index.tsx → /feed/42 (detail) ← L3 │ └── profile/ │ └── index.tsx → /profile └── search/ └── index.tsx → /search (outside tabs: full-screen)

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.

Component nesting (what renders)

__root.tsxproviders, html _app/route.tsxphone frame + status bar (tabs)/route.tsxbottom tab bar feed/$postId/index.tsxthe actual post screen

Four components nest like Russian dolls. Each <Outlet/> hands off to the next layer down, until the leaf route renders your actual UI.

URL → Components Walkthrough

This is the moment of truth — read a URL and predict the rendered component stack:

URLComponent stack (outer → inner)
/home __root_app(tabs)home/index
/feed/42 __root_app(tabs)feed/$postId/index (postId="42")
/search __rootsearch/index (no shell, no tabs)
⚡ The pattern to internalize Pathless layers (_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.

Practice Exercise

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.

Show solution
routes/ ├── __root.tsx ├── _app/ │ ├── route.tsx │ └── (tabs)/ │ ├── route.tsx │ ├── home/ │ │ └── index.tsx │ ├── feed/ │ │ ├── index.tsx │ │ └── $postId/ │ │ └── index.tsx │ └── profile/ │ ├── index.tsx → /profile │ └── u/ │ └── $username/ │ └── index.tsx → /profile/u/ana ├── search/ │ └── index.tsx └── settings/ ← sibling of _app: no tabs └── index.tsx → /settings

Why this works:

Quick Quiz

1. What does _app/route.tsx add to the URL?

The /app segment
Nothing at all
The /_app prefix
The _ prefix marks a pathless layout. Its route.tsx renders as a wrapper, but the folder name never appears in the URL.

2. Which file renders the bottom tab bar?

The __root file
The (tabs) route
The $param file
routes/_app/(tabs)/route.tsx is the tab layout — its <Outlet/> renders the active tab screen, with <BottomNav/> beside it.

3. URL /feed/42 matches which file?

feed/index.tsx
feed/$postId/index
_app/index.tsx
index.tsx matches /feed exactly; the $postId/index.tsx matches /feed/<anything>. 42 becomes params.postId.

4. How many pathless layers wrap the post detail screen?

Exactly one layer
Exactly two layers
Exactly zero layers
_app and (tabs) both wrap feed/$postId/index.tsx. Two pathless layouts, both invisible in the URL.

5. Why use a (group) instead of a normal folder for tabs?

Groups load data faster
Groups add no segment
Groups enable SSR here
A parenthesized folder is URL-invisible — it lets you group the tab routes and attach a tab-bar layout without polluting the URL with /tabs/.

6. Where does _app/route.tsx's <Outlet/> render into?

The root html tree
The matched child
The __root file
Each layout's <Outlet/> renders its deepest matched descendant — here the tab layout, which in turn renders the active tab screen.

7. The /feed list screen maps to which file?

feed/route.tsx
feed/index.tsx
feed/$postId
route.tsx is a layout; index.tsx is the exact-match leaf. /feed hits index.tsx, /feed/42 hits $postId/index.tsx.

8. /search shows no tab bar. Why?

Search disables the tabs
Search sits outside _app
Search uses a splat
search/ is a sibling of _app, not a child. Pathless layouts only wrap their descendants — so /search escapes both shell and tabs.

9. Adding a "Settings" tab inside the bar requires:

Editing __root only
settings/index in (tabs)
A new _settings folder
Drop settings/index.tsx inside (tabs)/. It inherits the tab bar automatically and resolves to /settings.

10. Which primitives combine in this capstone tree?

Server, client, and data
Pathless, group, dynamic
Search, params, and state
_app (pathless), (tabs) (group), and $postId (dynamic) — all three primitives from Lessons 1–3 composed into one mobile tree. Mission complete.
← Lesson 3: Dynamic Routes & Splats Lesson 5: Navigation →
🎓 Stuck on something?
Ask your teacher — why /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.