Skip to content
Chapter 29Lesson 5

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 children and detail
      • page.tsx fills children, the list
      • Directory@detail/
        • page.tsx fills the detail prop

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.

1 / 1

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.

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 children at /invoices
      • Directory@detail/
        • page.tsx empty placeholder, fills detail at /invoices
        • Directory[id]/
          • page.tsx the selected invoice, fills detail at /invoices/42

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:

app.example.com /invoices
children list
INV-041 $1,200
INV-042 $3,480
INV-043 $760
src/app/invoices/page.tsx
detail empty
Select an invoice
@detail/page.tsx
URL /invoices — children is filled by page.tsx (the list), and detail is filled by @detail/page.tsx (the empty placeholder). Two route trees, one URL.
app.example.com /invoices /42
children list
INV-041 $1,200
INV-042 $3,480
INV-043 $760
src/app/invoices/page.tsx
detail invoice 42
INV-042 Paid
$3,480.00 params.id = "42"
@detail/[id]/page.tsx
URL /invoices/42 — children is STILL page.tsx (the list, byte-for-byte unchanged, so it stays mounted), while detail is now filled by @detail/[id]/page.tsx with params.id = "42". Only the right pane swapped.

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:

src/app/invoices/@detail/[id]/page.tsx
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.

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.

soft nav · Link click
app.example.com /invoices /42
children kept
INV-041 $1,200
INV-042 $3,480
INV-043 $760
page.tsx
detail re-resolved
INV-042 Paid
$3,480.00
@detail/[id]/page.tsx
Soft navigation remembers the other slots. Clicking a row swaps only the detail slot; children's match did not change, so the router keeps it. Both slots stay filled — and this happy path is exactly what hides the bug.
hard nav · refresh
app.example.com /invoices /42
children match
INV-041 $1,200
INV-042 $3,480
INV-043 $760
page.tsx
detail match
INV-042 Paid
$3,480.00
@detail/[id]/page.tsx
On a hard load — a refresh, a direct visit, a new tab — there is no previous match to keep, so every slot is resolved from the URL alone. Here both slots still have a matching route, so both fill and no fallback is needed.
hard nav · shared link
app.example.com /invoices/42/edit
children matches
INV-041 $1,200
INV-042 $3,480
INV-043 $760
[id]/edit/page.tsx
detail no match
No route for this URL and no default.tsx
nothing to render
404 no fallback → the entire route 404s

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 detail at /invoices
        • default.tsx the unmatched-slot fallback
        • Directory[id]/
          • page.tsx fills detail at /invoices/42

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:

src/app/invoices/@detail/default.tsx
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.

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.

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

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.

ConventionWhat it isAdds a URL segment?What the layout receives
_folderPrivate folder, colocated non-routable codeNo, invisible to the routerNothing; it is not a route
(folder)Route group, organizes siblings and picks a layoutNoNormal children
@folderParallel slot, an independent route treeNoA 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

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:

Do you reach for parallel routes?

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
The browser issues a full document request for /invoices/42
The router resolves children against src/app/invoices/page.tsx (the list)
The router resolves @detail against @detail/[id]/page.tsx — it matches, so default.tsx is not used
The layout receives { children, detail }, both filled
The page renders with the list on the left and invoice 42 on the right

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.

The full convention surface, straight from the source, including the edge cases this lesson set up but did not pursue.