Parallel routes and slots
Next.js App Router parallel routes, the @slot folders that render a list and a detail pane as two independent route trees at one shareable URL.
You have seen this screen in every serious SaaS tool you use. In Gmail, Linear, or an invoices dashboard, a list of things runs down one side, and a detail pane on the other side updates the moment you click a row. The whole thing lives at one URL you can paste into a teammate’s chat, so they land on exactly the same invoice you were looking at. That is the screen you are going to build, and you already own most of the pieces. Layouts give you the shared shell. The [id] segment gives the detail pane a URL per invoice. <Link> makes clicking a row feel instant. One question remains before you open the editor: how do you render the list on the left and the selected invoice on the right as one screen, where each side gets its own URL, its own loading and error behavior, and clicking a row swaps only the detail without re-rendering the list?
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 route trees living at the same URL at the same time: the list tree and the detail tree, resolved independently and handed to one layout together. That capability has a name in the App Router, parallel routes, and it is built from folders prefixed with @. You will build the model in three passes. First, a slot is just another child the layout receives. Second, each slot is a full route tree of its own, which is what keeps the list in place while the detail changes. Third, there is one file you must not forget, because without it the page works for you and returns a 404 for everyone you share it with.
A slot is another child the layout receives
Section titled “A slot is another child the layout receives”Before any new folder rules, start with the one idea everything else builds on. Recall how layouts work: the layout receives children, and the framework fills it. You never pass children to a layout yourself, you receive it. Here is the reframe that makes most of this lesson feel familiar. That children prop is itself a slot, the layout’s unnamed slot, filled automatically with the matching segment’s page.tsx. A parallel route just adds a named slot beside it, filled the exact same way.
You create a named slot by adding a folder whose name starts with @. Picture the invoices route with a @detail folder dropped in next to its 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
That @detail folder does not add a URL segment. There is no /invoices/@detail route, and we will come back to why that matters. What it does is hand the invoices layout a second filled prop named detail, sitting right 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. You already know children: it’s the same-segment page. detail is new, and it follows the rule that drives this whole lesson. The prop name is the slot folder’s name minus the @, so @detail on disk becomes a detail prop in the layout. Add a @reviews folder and you would 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 always carries. That tells you a slot is filled with already-rendered UI, exactly like children: the framework hands you finished elements, not a component to call. These prop types are written by hand here so the link from slot to prop is impossible to miss. In production you would type the whole parameter with the generated LayoutProps<'/invoices'> helper, which names the slots for you. It comes from the same generated-types family as the PageProps you met in the dynamic-segments lesson, which the detail page below uses.
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 in the left column and detail in the right, but you could swap them, nest them, or wrap either one. The framework decides what is inside each slot, and you decide where it goes. It is the same contract as children, just a second one.
That is the entire core of the feature, and it really is this small: add a @x folder, get an x prop. Everything else in this lesson follows from that one rule: the list staying mounted, the streaming, and the one file you must not forget.
Slot is the word for one of these named regions, and you will see it everywhere in the App Router docs. With the vocabulary in place, 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”Here is the payoff, and it is bigger than “a layout with two props.” A @slot folder is not a single file you point at. 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 is the mechanism behind the list-plus-detail screen, so let’s build the real shape.
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: page.tsx is the list, and that is all it has. The detail tree is deeper: it has its own page.tsx, shown when no invoice is selected, and its own [id] segment, shown when one is. The URL decides what fills each tree, and it decides them separately. Walk the two URLs this screen lives at:
Look closely at what changed between those two steps, because it is the whole reason this feature exists. Navigating from /invoices to /invoices/42, the children slot’s match did not change: it is page.tsx, the list, in both. So the framework leaves the list pane exactly as it is, with the same DOM, the same scroll position, and the same client state in any toggles or filters the user touched. Only the detail slot resolved to something new, so only the right pane re-renders. Clicking a row navigates to a real, shareable URL, and the list does not re-render. For a decade, SaaS apps hand-rolled this behavior with client-side state machines: a “selected item” kept in React state, the detail conditionally rendered, and the URL out of sync with what was on screen. Parallel routes give you the same result URL-backed, refreshable, and shareable, 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} />;}Everything there is a habit you already have: 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 you would run for any dynamic route. The detail slot reads params; the list’s page.tsx would read searchParams to filter and sort, which is the same idea on the other slot. We are not building the filter here, since URL-driven list state is its own topic later in the course. The point to take away is that each slot reads its own slice of the URL, independently.
Step back and connect this to the layout boundary you already know. A layout stays mounted while the page beneath it changes, which is what made shared shells cheap. Parallel routes are that same idea, widened. The layout and the list pane both sit above the navigation that happened, so both stay mounted, while the one slot whose match changed swaps beneath them. It is nested-layout persistence with two independent below-the-fold trees instead of one.
default.tsx and the unmatched slot
Section titled “default.tsx and the unmatched slot”You can now build the screen, and it will work 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. Let’s make the failure visible so you can avoid it.
Start with how the router tracks slots. It holds one current match per slot, one for children and one for detail. When you click a <Link>, that is a soft navigation : the router re-resolves only the slots whose match actually changed and keeps the previous match for the others. That is exactly why clicking /invoices/42 updated detail and left children untouched. The children match did not change, so the router kept it.
Now the case that breaks. A hard navigation , meaning a direct visit to /invoices/42, a refresh, or a Cmd- or Ctrl-click that opens a new tab, starts from nothing. There is no previous match to keep, because there was no previous page. The router has to resolve every slot from the URL alone. If any slot has no route matching that URL, the framework has nothing to put there, and rather than render half a screen, it returns a 404 for the whole route, not just the empty slot but the entire page.
This is why the mistake is easy to miss. Clicking from /invoices to /invoices/42 is a soft navigation, and the router carries children’s match forward, so every slot is always filled and the screen looks right. The moment someone hard-loads a URL one of your slots cannot match, the whole route fails to render. Your dev session never hits that path, but your users hit it on their first click.
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 for the slot when, on a hard navigation, the URL matches none of the slot’s routes. It has the same default-export shape as page.tsx. Watch all three cases play out:
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.
So you add the file:
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;}This is 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 simply show nothing.
export default function Default() { return ( <p className="text-muted-foreground"> Select an invoice to see its details. </p> );}This is the same fallback, now with the empty state the user should actually see. For a detail pane, an explicit “nothing selected yet” prompt reads better than a blank rectangle. Both are legitimate, so choose based on whether the empty state deserves UI.
One subtlety is worth addressing, because you will wonder about it. If children is itself a slot, does it need a default.tsx too? In the simple single-segment shape here, no: children always matches the segment’s own page.tsx, so there is nothing to fall back from. 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 own “missing default” guidance explicitly covers the implicit children slot. So 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 rule that keeps you safe day to day, but it does not mean children is exempt.
Slots stream on their own
Section titled “Slots stream on their own”One more capability falls out of “each slot is its own route tree,” and it is worth naming even though we will not build it here. Because a slot is an independent tree, it can carry its own loading.tsx, and the framework wraps each slot in its own loading boundary. A slow query in the detail slot does not block the list from painting: the layout shell and the already-resolved list render immediately, the detail pane shows its own skeleton, and the detail streams in once its data is ready. Each slot loads on its own clock.
Directorysrc/app/
Directoryinvoices/
Directory@detail/
- loading.tsx skeleton shown only while the detail slot loads
- page.tsx
Directory[id]/
- page.tsx
That loading.tsx covers the detail slot alone. It shows while the detail is resolving and leaves the list, which is already on screen, untouched.
@slot, (folder), and _folder
Section titled “@slot, (folder), and _folder”You now know three folder conventions that change how routing behaves without adding a URL segment, and they are easy to confuse because they look similar and none of them show up in the URL. It is worth pinning the differences down. The key distinction is this: _folder is not a route at all, while (folder) and @folder are routes that simply 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 |
One line each: _folder is “not a route,” (folder) is “a route minus its URL segment,” and @folder is “a route that arrives as a named prop.” Hold onto that last one, because @detail is not a route group. You never write /invoices/@detail/.... The @ segment is invisible to the URL exactly like (group) is, but it surfaces as a prop rather than as more children. And a slot is scoped to one layout: the layout in the same folder as the @ directory. A parent layout or a child layout never 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 parallel routes earn their weight
Section titled “When parallel routes earn their weight”Now that you have this tool, the experienced move is knowing when not to reach for it. The default is still one page. Parallel routes are an escalation you reach for only when a specific threshold is crossed: two or more regions of one screen each need their own URL-driven state, loading, error, or not-found behavior, and you want both reflected in a single URL you can refresh and share. Cross that line and they are exactly right. Stay below it and they are overkill.
Three situations genuinely cross it. The first is the list-plus-detail surface we built and its cousins, any split screen where each pane carries independent URL state. The second is independent loading and error per region: a dashboard where the activity feed can throw its own error and show its own retry without taking down the KPI cards beside it, because each lives in its own slot with its own error boundary. The third is the modal-with-a-real-URL pattern, which lives in a @modal slot and is the subject of the next lesson.
There is also a trap here, because this is where the feature is most often overused. Two unrelated routes that merely share a layout are not parallel routes; that is nested layouts, which you already have. A single page that fetches two things is not parallel routes either; that is one page.tsx doing two reads. Parallel routes earn their complexity only when the regions each need independent URL, loading, or error behavior. If they don’t, a plain layout plus a page is simpler, and the simpler option wins. Think of parallel routes as the escalation from nested layouts, not a replacement for them: you climb to them when one layout with one page can no longer express what each region needs.
Here is the decision in the order an experienced engineer actually asks it. Walk it 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.
Recap and what’s next
Section titled “Recap and what’s next”Three sentences hold the whole lesson. A @folder gives the same-segment layout a named prop beside children: add a @x folder, get an x prop. Each slot is an independent route tree resolved from the same URL, which is why the list stays mounted while only the detail swaps. And every slot ships a default.tsx, or a hard navigation to an unmatched slot returns a 404 for the entire route.
Test the resolution model on the case that trips people up. Given the canonical invoices tree, put the events in order for a refresh on /invoices/42, which is a hard navigation, so every slot resolves fresh from the URL:
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 takes the exact slot machinery you just learned and adds one folder that intercepts navigation. The result is the pattern every polished SaaS app uses: click a row and the invoice opens in a modal over the list, but the URL is real, so refreshing or sharing it gives you the full invoice page. It is the same @slot you now understand, with one new piece on top.
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.