Parallel routes and slots
Next.js App Router parallel routes, the @slot folders that render a list and a detail pane as two route trees at one shareable URL.
You have seen this screen in every serious SaaS tool: a list of things down one side, a detail pane on the other that updates the moment you click a row. In Gmail, Linear, or an invoices dashboard, the whole thing lives at one URL you can paste into a teammate’s chat, so they land on exactly the invoice you were looking at. That is the screen you are going to build.
Everything you have learned so far renders one route tree at a URL: /invoices/42 resolves to a single chain of layouts wrapping a single page.
This screen needs two trees living at the same URL at once, the list and the detail, resolved independently and handed to one layout together.
That capability is called parallel routes, and you build it from folders prefixed with @.
A slot is another child the layout receives
Section titled “A slot is another child the layout receives”A layout receives children and the framework fills it with the matching segment’s page.tsx; you never pass it yourself.
That children prop is the layout’s unnamed slot.
A parallel route adds a named slot beside it, filled the same way.
You create one by adding a folder whose name starts with @.
Drop a @detail folder next to the invoices page:
Directorysrc/app/
Directoryinvoices/
- layout.tsx receives
childrenanddetail - page.tsx fills
children, the list Directory@detail/
- page.tsx fills the
detailprop
- page.tsx fills the
- layout.tsx receives
The @detail folder adds no URL segment: there is no /invoices/@detail route.
It hands the invoices layout a second filled prop, detail, sitting beside children.
Here is the layout that receives it:
export default function InvoicesLayout({ children, detail,}: { children: React.ReactNode; detail: React.ReactNode;}) { return ( <div className="grid grid-cols-[1fr_2fr] gap-6"> <section>{children}</section> <aside>{detail}</aside> </div> );}The layout destructures two props.
children is the same-segment page, as always.
detail is the new one: the prop name is the slot folder’s name minus the @, so @detail on disk becomes a detail prop.
Add a @reviews folder and you receive a reviews prop.
export default function InvoicesLayout({ children, detail,}: { children: React.ReactNode; detail: React.ReactNode;}) { return ( <div className="grid grid-cols-[1fr_2fr] gap-6"> <section>{children}</section> <aside>{detail}</aside> </div> );}Both are typed React.ReactNode, the same type children carries.
A slot is filled with already-rendered UI, not a component to call: the framework hands you finished elements.
These types are written by hand here to make the slot-to-prop link obvious; in production you would use the generated LayoutProps<'/invoices'> helper, which names the slots for you.
export default function InvoicesLayout({ children, detail,}: { children: React.ReactNode; detail: React.ReactNode;}) { return ( <div className="grid grid-cols-[1fr_2fr] gap-6"> <section>{children}</section> <aside>{detail}</aside> </div> );}You decide where each slot renders.
children goes left and detail right, but you could swap them, nest them, or wrap either one.
The framework decides what is inside each slot; you decide where it goes.
That is the whole core of the feature: add a @x folder, get an x prop.
Everything else follows from it, including the one file you must not forget.
Slot is the word for one of these named regions. Now let’s make a slot do something a plain prop cannot.
Each slot is its own route tree
Section titled “Each slot is its own route tree”A @slot folder is not a single file.
It is a complete, independent route subtree: it gets its own page.tsx, its own [id] dynamic segments, and its own loading.tsx and error.tsx.
When a URL comes in, the router matches it against every slot independently and hands all the matches to the layout at once.
That mechanism is what drives the list-plus-detail screen.
Directorysrc/app/
Directoryinvoices/
- layout.tsx receives
{ children, detail } - page.tsx the list, fills
childrenat/invoices Directory@detail/
- page.tsx empty placeholder, fills
detailat/invoices Directory[id]/
- page.tsx the selected invoice, fills
detailat/invoices/42
- page.tsx the selected invoice, fills
- page.tsx empty placeholder, fills
- layout.tsx receives
Read that tree as two route trees stacked in one folder.
The children tree is shallow: just page.tsx, the list.
The detail tree is deeper: a page.tsx shown when no invoice is selected, and an [id] segment shown when one is.
The URL fills each tree separately.
Here are the two URLs this screen lives at:
Navigating from /invoices to /invoices/42, the children slot’s match does not change: it is the list in both.
So the framework leaves the list pane untouched, with the same DOM, scroll position, and client state in any toggles or filters the user touched.
Only the detail slot resolves to something new, so only the right pane re-renders.
Clicking a row navigates to a real, shareable URL, and the list stays put.
The alternative, a “selected item” in React state with the detail conditionally rendered, leaves the URL out of sync with the screen; parallel routes give you the same result URL-backed and refreshable, for the price of a folder.
Because each slot is an ordinary route tree, the detail page is an ordinary dynamic page, with nothing special about living in a slot.
It reads its slice of the URL the same way any [id] page does:
export default async function InvoiceDetail({ params,}: PageProps<'/invoices/[id]'>) { const { id } = await params; // capture → validate → query return <InvoiceCard id={id} />;}It is the familiar shape: an async component, params typed with the generated PageProps<'/invoices/[id]'> helper, await params because request inputs are Promises in Next 16, then the capture-validate-query path.
The detail slot reads params; the list’s page.tsx would read searchParams to filter and sort, the same idea on the other slot.
We defer that filter, since URL-driven list state is its own topic later in the course.
Each slot reads its own slice of the URL, independently.
This widens the layout boundary you already know. A layout stays mounted while the page beneath it changes, which is what makes shared shells cheap. Here both the layout and the list pane sit above the navigation, so both stay mounted, while the one slot whose match changed swaps beneath them.
default.tsx and the unmatched slot
Section titled “default.tsx and the unmatched slot”You can build the screen, and it works while you click around in development.
Then you ship it, a user opens a shared /invoices/42 link in a fresh tab, and the entire page returns a 404.
This is the most common parallel-routes mistake, and it slips through because the path you exercise in development never triggers it.
The router holds one current match per slot, here one for children and one for detail.
Clicking a <Link> is a soft navigation : the router re-resolves only the slots whose match changed and keeps the previous match for the others.
That is why clicking /invoices/42 updated detail but left children untouched.
A hard navigation , a direct visit, a refresh, or a Cmd- or Ctrl-click into a new tab, starts from nothing.
There is no previous page, so no match to keep, and the router must resolve every slot from the URL alone.
If any slot has no route for that URL, the framework returns a 404 for the whole route rather than render half a screen.
That is why the mistake hides.
Clicking from /invoices to /invoices/42 is a soft navigation that carries children’s match forward, so every slot stays filled and the screen looks right.
Your dev session never hits an unmatched slot; your users hit it on their first shared link.
The fix is one file: default.tsx.
A default.tsx inside a slot folder is that slot’s fallback for the unmatched case, what the framework renders when a hard navigation matches none of the slot’s routes.
It has the same default-export shape as page.tsx.
default.tsx is the difference between a shareable URL and a 404. On a hard load to a path the detail
slot can't match, a missing fallback 404s the whole route — switch the tab to add one default.tsx and
the slot falls back, the route survives.
Directorysrc/app/
Directoryinvoices/
- layout.tsx
- page.tsx fills
children, the list Directory@detail/
- page.tsx fills
detailat/invoices - default.tsx the unmatched-slot fallback
Directory[id]/
- page.tsx fills
detailat/invoices/42
- page.tsx fills
- page.tsx fills
Carry one habit out of this section: every parallel slot ships a default.tsx.
Add it as automatically as you add a key to a list.
What goes in the file depends on whether the empty state needs UI:
export default function Default() { return null;}The minimum that satisfies the rule: the slot renders nothing on the unmatched case and the route still loads. Use this when an empty slot should show nothing.
export default function Default() { return ( <p className="text-muted-foreground"> Select an invoice to see its details. </p> );}The same fallback, now with the empty state the user should see. For a detail pane, an explicit “nothing selected yet” prompt reads better than a blank rectangle.
One subtlety: if children is itself a slot, does it need a default.tsx?
In the single-segment shape here, no, since children always matches the segment’s own page.tsx.
But once you nest slots more deeply, the framework can fail to recover children’s active state on a hard load, and Next.js’s “missing default” guidance covers the implicit children slot too.
The durable rule is broader than “every named slot”: a default.tsx is needed wherever any slot, named or the implicit children, can go unmatched on a hard navigation.
“Every named slot ships one” is the practical version that keeps you safe day to day, not a sign children is exempt.
Each slot is its own loading boundary
Section titled “Each slot is its own loading boundary”Because each slot is its own route tree, each can carry its own loading.tsx, and the framework wraps each slot in its own loading boundary.
A slow query in the detail slot then shows a skeleton there without blocking the list, which has already rendered.
Directorysrc/app/
Directoryinvoices/
Directory@detail/
- loading.tsx skeleton shown only while the detail slot loads
- page.tsx
Directory[id]/
- page.tsx
@slot, (folder), and _folder
Section titled “@slot, (folder), and _folder”These three folder conventions all change routing without adding a URL segment, which makes them easy to confuse.
The key split: _folder is not a route at all, while (folder) and @folder are routes that contribute no URL segment.
Those two differ in what they hand the layout.
| Convention | What it is | Adds a URL segment? | What the layout receives |
|---|---|---|---|
_folder | Private folder, colocated non-routable code | No, invisible to the router | Nothing; it is not a route |
(folder) | Route group, organizes siblings and picks a layout | No | Normal children |
@folder | Parallel slot, an independent route tree | No | A named prop beside children |
A slot is not a route group: you never write /invoices/@detail/....
The @ segment is invisible to the URL just like (group), but it surfaces as a prop rather than as children.
A slot is also scoped to the layout in its own folder; no parent or child layout ever sees it.
Given this folder, which name does the framework hand to dashboard/layout.tsx as a named prop?
src/app/dashboard/ _lib/ (settings)/ [team]/ @activity/_lib(settings)[team]@activity@ prefix creates a slot, and a slot reaches the layout as a prop named after the folder with the @ dropped — so @activity arrives as activity. _lib is private code the router never sees, (settings) is a route group whose page flows in through the ordinary children prop, and [team] is a dynamic URL segment, not a slot.When to reach for parallel routes
Section titled “When to reach for parallel routes”The default is one page. Parallel routes are an escalation, worth it only past a specific threshold: two or more regions of one screen each need their own URL-driven state, loading, error, or not-found behavior, all reflected in a single URL you can refresh and share. Below that line they are overkill.
Three situations cross it.
First, the list-plus-detail surface we built and any split screen where each pane carries its own URL state.
Second, independent loading and error per region: a dashboard where the activity feed throws its own error and shows its own retry while the KPI cards beside it stay up, because each slot has its own error boundary.
Third, the modal-with-a-real-URL pattern, which lives in a @modal slot and is the next lesson.
This is also where the feature is most often overused.
Two unrelated routes that share a layout are nested layouts, which you already have, not parallel routes.
A single page that fetches two things is one page.tsx doing two reads.
Reach for parallel routes only when each region needs independent URL, loading, or error behavior; otherwise a layout plus a page is simpler.
Treat them as the escalation from nested layouts, for when one layout and one page can no longer express what each region needs.
Walk this decision for a screen you are designing:
A single cohesive view doesn’t need independent route trees. Read what it needs in one page.tsx; share a shell with a layout if siblings reuse it.
If the regions don’t need their own URL, you’re describing a shared shell or a page that fetches a few things. Nested layouts or a single page reading in parallel is simpler, so stay there.
Each region becomes its own slot beside children, resolved independently from the same URL. Ship a default.tsx for every slot.
The @slot machinery you just learned, plus one folder that intercepts navigation so the same content is a modal over the list and a full page on direct visit. That’s the next lesson.
Practice and what’s next
Section titled “Practice and what’s next”A refresh is a hard navigation, so every slot resolves fresh from the URL.
Order what the router does to rebuild /invoices/42:
A user refreshes the page on /invoices/42. Order what the router does to resolve it. Drag the items into the correct order, then press Check.
src/app/invoices/ layout.tsx page.tsx @detail/ page.tsx default.tsx [id]/ page.tsx/invoices/42 children against src/app/invoices/page.tsx (the list) @detail against @detail/[id]/page.tsx — it matches, so default.tsx is not used { children, detail }, both filled The next lesson adds one folder that intercepts navigation: clicking a row opens the invoice in a modal over the list, but the URL is real, so refreshing or sharing it loads the full invoice page.
External resources
Section titled “External resources”The full convention surface, straight from the source, including the edge cases this lesson set up but did not pursue.