Parallel routes
Render several pages in one layout with @slot folders: a staff dashboard whose panels navigate, load and fail on their own, with default.tsx for reloads.
- 01Build a layout from slotsAdd @slot folders, place each slot in a layout, and say which address every page in a slot has.
- 02Know what each slot showsPredict what a slot shows after a click and after a reload, and write the default.tsx Next.js 16.4 asks for.
- 03Give each slot its own states and checksAdd loading.tsx and error.tsx to one slot, and check a visitor's role inside the slot that needs it.
Two panels beside the page, from two folders
The tea shop's staff start each day on a dashboard. It shows the day's orders and the stock side by side. You could draw both in one page component. But each panel has its own data, and soon its own loading message, its own error screen and its own tabs. Parallel routes let one layout show several pages at the same time, each from a folder of its own.
A folder whose name starts with @, like @orders, is a slot. The page.tsx inside it is a page like any other, but it doesn't get an address of its own. Instead, it goes to the layout of the folder above, as a prop named after the slot: @orders becomes the prop orders. Templates and the file hierarchy gave a first look at a single slot. Here is the dashboard with two, as a real Next.js 16.4.3 project: built with next build, run with next start, and recorded.
app/dashboard/layout.tsxexport default function DashboardLayout({ children, orders, stock }: LayoutProps<"/dashboard">) { return ( <main> {children} <section aria-label="Orders">{orders}</section> <section aria-label="Stock">{stock}</section> </main> );}- Order 1041: Green tea
- Order 1042: Mint tea
Green tea: 12 boxes
Mint tea: 3 boxes
The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.
The file shown first is app/dashboard/layout.tsx. Line 1 takes three props: children, orders and stock. Lines 4 to 6 place them: the page first, then the orders and the stock, each in a <section> with a label. LayoutProps<"/dashboard"> is the type Next.js writes for this layout. It knows the folder holds @orders and @stock, so it includes both props, and the build's type check passed.
Open the Terminal tab. The route table lists ○ /dashboard, and no address with orders or stock in it. In the Browser tab, /dashboard shows Today at the tea shop, then the orders and the stock. Pick /dashboard/orders and /dashboard/@orders: both answered 404. A slot is not a route segment (one part of the address, between two slashes), so its name never shows in the address, with or without the @.
There's a third slot in this layout: children. It has no @ folder. The page beside the layout, app/dashboard/page.tsx, fills it, the way a page always fills its layout's children. Next.js treats it like the other slots, and its error messages name it beside them when a slot has nothing to show. Each row is one page file of the dashboard: the prop it fills, and what it shows at /dashboard.
| File | The prop it fills | What it shows |
|---|---|---|
app/dashboard/page.tsx | children | Today at the tea shop |
app/dashboard/@orders/page.tsx | orders | Orders, and two orders |
app/dashboard/@stock/page.tsx | stock | Stock, and two teas |
page.tsx fills children, and each @ folder fills the prop with its name. All three show at /dashboard.A lunch box has one name on its lid, but inside it has a big compartment and a few small ones. The cook fills each one from a different pot. Tomorrow, the cook can swap what's in one small compartment and leave the others as they are.
- In real life: The lunch box, with one name on the lid
- In Next.js: The layout, at one address:
/dashboard - In real life: The big compartment, for the main dish
- In Next.js:
children, filled bypage.tsx - In real life: The small compartments, each with a label
- In Next.js: The slots,
@ordersand@stock - In real life: Each compartment filled from its own pot
- In Next.js: Each slot's page, from its own folder
- In real life: Swapping the food in one compartment, and leaving the rest
- In Next.js: A click that changes one slot and keeps the others
A folder named @something is a slot: its pages render inside the parent layout, as the prop something, at the same address as the layout's page. The layout places the slots where it wants them. children is a slot too, filled by the page.tsx beside the layout.
After a reload: default.tsx
Step 4 reloads /dashboard/late. A reload, an address typed into the browser, or a link from another site is a hard navigation: a full page load, where the browser asks the server for a whole new HTML page. The server only has the address. It can't know which page the stock slot showed a moment ago, so there's nothing to keep.
That's what default.tsx is for. It's what a slot shows when it has no page for the address and no earlier page to keep. This dashboard has two:
app/dashboard/@stock/default.tsxreturnsStock is on the dashboard's first tab., for the stock slot.app/dashboard/default.tsxreturnsDashboard, forchildren.
app/dashboard/@stock/default.tsxexport default function StockDefault() { return <p>Stock is on the dashboard's first tab.</p>;}Each step was taken in real Chrome against next start. Pick one to see the page after it.
- Order 1042: Mint tea, 2 days late
Stock is on the dashboard's first tab.
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (8), without the files it loaded
GET/dashboard/late200a whole pageGET/200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /dashboard/late
GET/dashboard200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /dashboard/late
GET/dashboard/late200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /dashboard/late
GET/200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /__PAGE__
- next-url
- /dashboard/late
GET/dashboard200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /dashboard/__PAGE__
- next-url
- /dashboard/late
GET/dashboard200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /dashboard/@orders/!KF9fU0xPVF9fKQ/__PAGE__
- next-url
- /dashboard/late
GET/dashboard200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /dashboard/@stock/!KF9fU0xPVF9fKQ/__PAGE__
- next-url
- /dashboard/late
In step 4, the page shows Dashboard from the first default, the late orders from @orders/late/page.tsx, and Stock is on the dashboard's first tab. from the stock's default. Its requests start with one document, the whole page. Step 5 clicks All orders, and all three slots have pages for /dashboard again. Each row is one slot at /dashboard/late: what it showed after the click, and after the reload.
| Slot | After a click from /dashboard | After a reload |
|---|---|---|
children | Today at the tea shop, kept from /dashboard. | Dashboard, from app/dashboard/default.tsx. |
orders | The late orders, from @orders/late/page.tsx. | The late orders, from @orders/late/page.tsx. |
stock | The stock, kept from /dashboard. | Stock is on the dashboard's first tab., from @stock/default.tsx. |
/dashboard/late. After a reload, it only has the address, so those slots show their default.tsx.Now leave files out. Each version below changes only the default.tsx files. Before you click, guess which ones build, and what a reload of /dashboard/late shows in each.
app/dashboard/layout.tsxexport default function DashboardLayout({ children, orders, stock }: LayoutProps<"/dashboard">) { return ( <main> {children} <section aria-label="Orders">{orders}</section> <section aria-label="Stock">{stock}</section> </main> );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...⚠ Strict route matching is enabled by default. This validation indicates a bug in your app's route structure, but you can temporarily restore loose route matching by setting `deprecated.looseRouteMatching` to `true` in your Next.js config. > Build error occurredError: Turbopack build failed with 2 errors:./app/dashboard/layout.tsxError: Parallel route slots cannot render the same URLsThe following layouts have parallel route slots that cannot render the same URLs:app/dashboard/layout.tsx- /dashboard/late is missing a matching page or default.tsx in @stock, children Every URL matched by one slot must have a matching page or default.tsx in every sibling slot. ./app/dashboard/@orders/late/page.tsxError: Unmatched app pagesThe following page files do not match any complete route:- app/dashboard/@orders/late/page.tsx Every page must be part of at least one complete route. Add matching pages or default files for the sibling parallel route slots, or remove the unreachable pages. at ignore-listed framesNeither @stock nor children has anything for /dashboard/late. The build stops: /dashboard/late is missing a matching page or default.tsx in @stock, children.
Without any default.tsx, the build stops: /dashboard/late is missing a matching page or default.tsx in @stock, children, and Every URL matched by one slot must have a matching page or default.tsx in every sibling slot. A second message, Unmatched app pages, lists app/dashboard/@orders/late/page.tsx: with nothing beside it in the other slots, that page could never be shown. With only the stock's default, the build names children alone. With defaults that call notFound(), the click still keeps every slot, and the reload answers 404 for the whole page, with This page could not be found.
The docs and the recordings say this in two different ways:
- The docs. The Next.js 16 upgrade guide says all parallel route slots now need a
default.js, and that builds fail without them. It suggests one that callsnotFound()to keep the older behavior: a 404 on the reload. The docs page fordefault.jsstill says a missing default forchildrengives a 404. - What Next.js 16.4.3 did here. Its strict route matching, on by default since Next.js 16.4, checks each address the layout can show against every slot,
childrenincluded. A slot needs adefault.tsxonly when one of those addresses has no page in it: the first dashboard had one address and a page in every slot, and it built without anydefault.tsx. Whenchildrenhad none for/dashboard/late, the build stopped instead of answering 404. Its warning is the line that starts with⚠at the top of each failed build.
The last version is the one most dashboards want. Each default is one line, which shows the slot's usual page:
export { default } from "./page";
After a reload of /dashboard/late, the heading and the stock look exactly as they did after the click. So choose, for each slot, what it should show when nothing matches: its usual page, nothing (return null), a message, or a 404.
The switch is in lines 460 to 468 of server/config.ts: strict route matching is on by default, and deprecated.looseRouteMatching in next.config.ts turns it off. The warning calls that a temporary way back, and says the check found a bug in the app's routes.
Each slot loads and fails on its own
Each slot can have its own loading.tsx and error.tsx, the files from Loading UI and Error handling. In a slot's folder, they wrap that slot's pages. Here both panels ask the shop's services for their data. The services are a stand-in that the recorder runs beside the app, at the address in STAFF_API_URL: the order service answers with two orders, and the stock service answers 500, as if it were down.
app/dashboard/@stock/error.tsx"use client"; export default function StockError({ retry }: { retry: () => void }) { return ( <> <h2>Stock</h2> <p>The stock did not load.</p> <button onClick={retry}>Try again</button> </> );}GET /dashboard200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-stale-time300x-nextjs-prerender1x-nextjs-postponed1X-Powered-ByNext.jsCache-Controlprivate, no-cache, no-store, max-age=0, must-revalidateContent-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 17 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1x6obpmlu1_c2.js">
<script src="/_next/static/chunks/06bs-nf19b2js.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/3l_qmp-61z4yc.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/3oh00mkbkq8u6.js" async=""></script>
<script src="/_next/static/chunks/1z8gtk4_z506a.js" async=""></script>
<title>Tea shop staff</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<nav><a href="/">Home</a> <a href="/dashboard">Dashboard</a></nav>
<!--&-->
<main>
<!--&-->
<h1>Today at the tea shop</h1>
<!--$-->
<!--/$-->
<!--/&-->
<section aria-label="Orders">
<!--&-->
<!--&-->
<!--$?-->
<template id="B:0"></template>
<p>Loading orders…</p>
<!--/$-->
<!--/&-->
<!--/&-->
</section>
<section aria-label="Stock">
<!--&-->
<!--&-->
<!--$?-->
<template id="B:1"></template>
<p>Loading stock…</p>
<!--/$-->
<!--/&-->
<!--/&-->
</section>
</main>
<!--/&-->
<script>…</script>
<script id="_R_">…</script>
<script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
<script>…</script>
<div hidden="" id="S:0">
<h2>Orders</h2>
<ul>
<li>Order <!-- -->1041<!-- -->: <!-- -->Green tea</li>
<li>Order <!-- -->1042<!-- -->: <!-- -->Mint tea</li>
</ul>
</div>
<script>…</script>
<script>…</script>
</body>
</html>In app/dashboard/@stock/page.tsx, the file shown first, line 4 asks for the stock and line 5 throws when the answer isn't OK. In app/dashboard/@orders/page.tsx, line 4 asks for the orders. Each slot has a loading.tsx, and the stock slot also has an error.tsx, a Client Component whose line 8 offers Try again.
The Terminal tab says ◐ /dashboard: prerendered as static HTML with dynamic server-streamed content. With Cache Components on, as in every example here and in new projects, the build made a static shell for the page: everything that doesn't wait for a request. Both fetches wait for a service when a request comes in, so each slot's loading.tsx went into the shell in their place. The Network tab shows the HTML as it arrived. Its first part shows Today at the tea shop, Loading orders… and Loading stock…. Then the orders streamed into their slot. Loading stock… stayed in the HTML: error.tsx is an error boundary that React shows in the browser.
app/dashboard/@stock/error.tsx"use client"; export default function StockError({ retry }: { retry: () => void }) { return ( <> <h2>Stock</h2> <p>The stock did not load.</p> <button onClick={retry}>Try again</button> </> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
- Order 1041: Green tea
- Order 1042: Mint tea
The stock did not load.
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (1), without the files it loaded
GET/200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /__PAGE__
- next-url
- /dashboard
In Chrome, step 1 opened /dashboard and waited for The stock did not load., from line 7 of error.tsx. The step lists the error React logged in the console when the stock slot failed. Step 2 waited for the orders. After it, the page shows the heading, both orders, and the stock slot's error screen with its Try again button. One slot failed, and the other two kept working.
/dashboard, with each slot's loading fallback in it. Then each slot finished on its own: the orders arrived, and the stock slot showed its error.tsx.A slot doesn't make its own route: the page and all its slots are one route, with one symbol in the route table and one shell. That's why the docs say you can't prerender one slot while another at the same level renders for each request. If one is dynamic, the route is. With Cache Components, that one route is ◐: its shell holds the static parts of every slot, and the parts that wait for a request stream in, each behind its own boundary. The first dashboard, which read nothing when a request came in, was ○ /dashboard, all of it prerendered.
Choosing a slot by role, on the server
A layout can also choose which slots to show. The shop's owner should see the day's sales, and the rest of the staff their next task. So the dashboard gets two slots, @admin and @staff, and its layout returns one of them by the visitor's role. Here the role is a cookie called role. That's a stand-in: anyone can set a cookie in their own browser, so a real app reads the role from a signed-in session, which the authentication lessons build.
app/dashboard/layout.tsximport { Suspense } from "react";import { cookies } from "next/headers"; async function ForRole({ admin, staff }: { admin: React.ReactNode; staff: React.ReactNode }) { const role = (await cookies()).get("role")?.value; return role === "admin" ? admin : staff;} export default function DashboardLayout({ children, admin, staff }: LayoutProps<"/dashboard">) { return ( <main> {children} <Suspense fallback={<p>Checking your role…</p>}> <ForRole admin={admin} staff={staff} /> </Suspense> </main> );}Sales today: $1,240
Sent with cookie: role=admin. The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.
Lines 4 to 7 of app/dashboard/layout.tsx are ForRole, an async Server Component. Line 5 reads the cookie with cookies() from next/headers, and line 6 returns admin or staff. A cookie only exists when a request comes in, so lines 13 to 15 wrap ForRole in Suspense, with Checking your role… in the shell. Request-time APIs explains why. In the Browser tab, the owner, asked for with role=admin, sees Sales today: $1,240, and a member of staff sees Your next task: pack order 1042.
It looks right. Now open the Network tab and pick the third response: the RSC payload that the same member of staff gets. That's the data React uses to draw what the Server Components rendered; the router asks for it on client-side navigations. Search its body for Sales today: it's there. Next.js renders every slot of a route and sends each one's result, whatever the layout returns. The layout's condition decides what shows on the screen. It doesn't decide what runs, or what's sent.
So check the role where the data is: in the slot itself. Try both versions, and search each payload for the sales:
app/dashboard/layout.tsximport { Suspense } from "react";import { cookies } from "next/headers"; async function ForRole({ admin, staff }: { admin: React.ReactNode; staff: React.ReactNode }) { const role = (await cookies()).get("role")?.value; return role === "admin" ? admin : staff;} export default function DashboardLayout({ children, admin, staff }: LayoutProps<"/dashboard">) { return ( <main> {children} <Suspense fallback={<p>Checking your role…</p>}> <ForRole admin={admin} staff={staff} /> </Suspense> </main> );}GET /dashboard?_rsc=200
- Sent with the request
rsc1cookierole=staff
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-EncodingCache-Controlprivate, no-cache, no-store, max-age=0, must-revalidateContent-Typetext/x-component
The body that arrived, as it arrived: the RSC payload, one row per line
2:"$Sreact.fragment"
3:"/_next/static/chunks/3oh00mkbkq8u6.js"
4:I[22016,["$3"],""]
5:I[39756,["$3"],"default"]
6:I[37457,["$3"],"default"]
9:"$Sreact.suspense"
d:I[97367,["$3"],"OutletBoundary"]
15:"ViewportBoundary"
16:I[97367,["$3"],"$15"]
18:"MetadataBoundary"
19:I[97367,["$3"],"$18"]
1:X
7:X
b:X
f:X
10:X
12:X
11:[["children",{"s":"__PAGE__","h":34,"d":{"r":["$","$2","c",{"children":[["$","p",null,{"children":"Sales today: $1,240"}],null,null]}],"p":false,"v":"$12"}}]]
14:X
13:[["children",{"s":"__PAGE__","h":34,"d":{"r":["$","$2","c",{"children":[["$","p",null,{"children":"Your next task: pack order 1042."}],null,null]}],"p":false,"v":"$14"}}]]
c:[["children",{"s":"__PAGE__","h":162,"d":{"r":["$","$2","c",{"children":[["$","h1",null,{"children":"Today at the tea shop"}],null,["$","$Ld",null,{"children":["$","$9",null,{"name":"Next.MetadataOutlet","children":"$@e"}]}]]}],"p":false,"v":"$f"}}],["admin",{"s":"(__SLOT__)","h":66,"d":{"r":["$","$2","c",{"children":[null,["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],"p":false,"v":"$10"},"c":"$Q11"}],["staff",{"s":"(__SLOT__)","h":66,"d":{"r":["$","$2","c",{"children":[null,["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],"p":false,"v":"$10"},"c":"$Q13"}]]
8:[["children",{"s":"dashboard","h":98,"d":{"r":["$","$2","c",{"children":[null,["$","main",null,{"children":[["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}],["$","$9",null,{"fallback":["$","p",null,{"children":"Checking your role…"}],"children":"$La"}]]}]]}],"p":false,"v":"$b"},"c":"$Qc"}]]
1b:X
1c:X
1f:R
0:{"q":"","i":false,"S":true,"r":"$1","b":"cf5eAV0mV1PbLin4mGz4e","t":{"t":{"s":"","h":82,"d":{"r":["$","$2","c",{"children":[[["$","script","script-0",{"src":"/_next/static/chunks/3oh00mkbkq8u6.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":[["$","nav",null,{"children":[["$","$L4",null,{"href":"/","children":"Home"}]," ",["$","$L4",null,{"href":"/dashboard","children":"Dashboard"}]]}],["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]]}]}]]}],"p":false,"v":"$7"},"c":"$Q8"},"h":{"r":["$","$2","h",{"children":[null,["$","$L16","B2_SjCFpbRWVcISSk4J8mv",{"children":"$L17"}],["$","$L19","B2_SjCFpbRWVcISSk4J8mm",{"children":[["$","div",null,{"hidden":true,"children":["$","$9",null,{"name":"Next.Metadata","children":"$L1a"}]}],null]}]]}],"p":false,"v":"$1b"}},"s":"$1c","l":"$@1d","a":"$@1e","p":"$1f"}
10:C
1c:300
17:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
e:null
1a:[["$","title","0",{"children":"Tea shop staff"}]]
1c:C
1b:C
1:C
f:C
12:C
14:C
b:C
7:C
a:["$","$L5",null,{"parallelRouterKey":"staff","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]
1d:4702
1e:4666
1f:o1,#1f:o8e5,2:"$Sreact.fragment"
3:"/_next/static/chunks/3oh00mkbkq8u6.js"
4:I[22016,["$3"],""]
5:I[39756,["$3"],"default"]
6:I[37457,["$3"],"default"]
9:"$Sreact.suspense"
d:I[97367,["$3"],"OutletBoundary"]
15:"ViewportBoundary"
16:I[97367,["$3"],"$15"]
18:"MetadataBoundary"
19:I[97367,["$3"],"$18"]
1:X
7:X
b:X
f:X
10:X
12:X
11:[["children",{"s":"__PAGE__","h":34,"d":{"r":["$","$2","c",{"children":[["$","p",null,{"children":"Sales today: $1,240"}],null,null]}],"p":false,"v":"$12"}}]]
14:X
13:[["children",{"s":"__PAGE__","h":34,"d":{"r":["$","$2","c",{"children":[["$","p",null,{"children":"Your next task: pack order 1042."}],null,null]}],"p":false,"v":"$14"}}]]
c:[["children",{"s":"__PAGE__","h":162,"d":{"r":["$","$2","c",{"children":[["$","h1",null,{"children":"Today at the tea shop"}],null,["$","$Ld",null,{"children":["$","$9",null,{"name":"Next.MetadataOutlet","children":"$@e"}]}]]}],"p":false,"v":"$f"}}],["admin",{"s":"(__SLOT__)","h":66,"d":{"r":["$","$2","c",{"children":[null,["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],"p":false,"v":"$10"},"c":"$Q11"}],["staff",{"s":"(__SLOT__)","h":66,"d":{"r":["$","$2","c",{"children":[null,["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],"p":false,"v":"$10"},"c":"$Q13"}]]
8:[["children",{"s":"dashboard","h":98,"d":{"r":["$","$2","c",{"children":[null,["$","main",null,{"children":[["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}],["$","$9",null,{"fallback":["$","p",null,{"children":"Checking your role…"}],"children":"$La"}]]}]]}],"p":false,"v":"$b"},"c":"$Qc"}]]
1b:X
1c:X
1f:o87f,0:{"q":"","i":false,"S":true,"r":"$1","b":"cf5eAV0mV1PbLin4mGz4e","t":{"t":{"s":"","h":82,"d":{"r":["$","$2","c",{"children":[[["$","script","script-0",{"src":"/_next/static/chunks/3oh00mkbkq8u6.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":[["$","nav",null,{"children":[["$","$L4",null,{"href":"/","children":"Home"}]," ",["$","$L4",null,{"href":"/dashboard","children":"Dashboard"}]]}],["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]]}]}]]}],"p":false,"v":"$7"},"c":"$Q8"},"h":{"r":["$","$2","h",{"children":[null,["$","$L16","B2_SjCFpbRWVcISSk4J8mv",{"children":"$L17"}],["$","$L19","B2_SjCFpbRWVcISSk4J8mm",{"children":[["$","div",null,{"hidden":true,"children":["$","$9",null,{"name":"Next.Metadata","children":"$L1a"}]}],null]}]]}],"p":false,"v":"$1b"}},"s":"$1c","a":"$@1d","w":"$@1e"}
1f:o8b,10:C
1c:300
17:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
1f:o3c,e:null
1a:[["$","title","0",{"children":"Tea shop staff"}]]
1f:o126,a:["$","$L5",null,{"parallelRouterKey":"staff","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]
1f:o13,1b:0
f:0
12:0
14:0
1f:o10,1e:true
1d:4945
1f:o24,1c:C
1b:C
1:C
f:C
12:C
14:C
b:C
7:C
1f:C
Staff see their task, but the RSC payload they receive still holds Sales today: $1,240: Next.js rendered @admin for them too.
In the second version, app/dashboard/@admin/page.tsx asks for the role itself. Line 1 imports getRole from app/role.ts, one small module that reads the cookie, and line 4 returns null for anyone who isn't the owner. The payload a member of staff gets no longer holds Sales today. Each row is one version: what staff see, and whether the sales reach their browser.
| Where the role is checked | What staff see | Do they receive the sales? |
|---|---|---|
| Only the layout checks the role | Your next task: pack order 1042. | Yes. The RSC payload holds Sales today: $1,240. |
@admin/page.tsx checks it too | Your next task: pack order 1042. | No. The admin slot rendered nothing for staff. |
@admin for a member of staff, and sent its result in the RSC payload. Check the role inside the slot.Keep the layout's choice for what the page looks like, and put the check next to the data. Next.js's docs suggest a data access layer for this: one module that checks who is asking before it returns any private data, used by every page and slot that needs it. Here, import { getRole } from "@/app/role"; is the start of one.
Planning the staff dashboard's files
When you add a slot, ask four questions about it. Each answer is a file in the slot's folder, or a line in its page:
- What does it show at each address? A
page.tsxfor the addresses it has content for, and adefault.tsxfor every other address its siblings can show. The build checks this for you. - Does it wait for anything? A
loading.tsx, so the shell has something to show in its place. - Can it fail? An
error.tsx, so its failure stays in its own panel. - Who may see it? A check inside the slot's page, or in the data module it calls, not only in the layout.
Sort these moments in the dashboard's day by the file that handles them:
- The stock slot, after a reload of
/dashboard/late - The dashboard's own page, after a reload of
/dashboard/late - The orders slot while the order service answers
- A message in the page's shell for one slot
- The stock slot when the stock service is down
- A Try again button in the stock panel only
Sort each moment by the file in the slot's folder that decides what shows.
Common mix-ups
- "A slot's folder is part of the address." No.
/dashboard/ordersand/dashboard/@ordersanswered404, and@orders/late/page.tsxis/dashboard/late. - "
childrenisn't a slot." It is, one without an@folder. The build named it beside@stockwhen it had nodefault.tsx. - "A click and a reload show the same thing." Not with slots. The click kept the stock; the reload showed its
default.tsx. - "Without
default.tsx, a reload shows a 404." Not in Next.js 16.4.3: the build stops first. Adefault.tsxthat callsnotFound()gives the 404 on purpose. - "One failing panel breaks the whole dashboard." No. With an
error.tsxin its slot, the stock failed and the orders still showed. - "A slot the layout doesn't return never runs." No. Next.js renders every slot and sends its result: the sales were in the staff's RSC payload.
Each row is one special file in a slot's folder: when it shows, and what it's for.
| File | When it shows | What it's for |
|---|---|---|
page.tsx in a slot | When the address matches it. | The slot's own content, like @orders/late/page.tsx for /dashboard/late. |
default.tsx | After a reload, when the slot has no page for the address. | A fallback. In Next.js 16.4, every slot that can be unmatched needs one, children too. |
loading.tsx | While the slot's page waits. | That slot's fallback, sent in the page's shell. |
error.tsx | When the slot's page throws. | That slot's error screen. The other slots keep working. |
layout.tsx in a slot | Around every page of that slot. | Parts the slot's pages share, like tabs. |
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
At which address does app/dashboard/@stock/page.tsx show?
app/
dashboard/
@orders/
page.tsx
@stock/
page.tsx
layout.tsx
page.tsx
layout.tsx
page.tsx/dashboard. @stock is a slot, so its page.tsx shows beside the dashboard's page, at the dashboard's address. The recorded build lists ○ /dashboard and no other dashboard route.
A member of staff is on /dashboard and clicks Late orders. What does the stock slot show now?
app/
dashboard/
@orders/
late/
page.tsx
layout.tsx
order-tabs.tsx
page.tsx
@stock/
default.tsx
page.tsx
default.tsx
layout.tsx
page.tsx
layout.tsx
page.tsxThe stock, as before: Green tea: 12 boxes and Mint tea: 3 boxes. A click is a soft navigation, so Next.js kept the stock slot's page. The recorded step 3 shows it.
The same member of staff now reloads /dashboard/late. This is app/dashboard/@stock/default.tsx. What does the stock slot show?
export default function StockDefault() {
return <p>Stock is on the dashboard's first tab.</p>;
}
Stock is on the dashboard's first tab., from this default.tsx. After a reload, the stock slot has no page for /dashboard/late and no earlier page to keep. The recorded step 4 shows it.
With these files, next build stops: /dashboard/late is missing a matching page or default.tsx in children. Which file is missing?
app/
dashboard/
@orders/
late/
page.tsx
layout.tsx
order-tabs.tsx
page.tsx
@stock/
default.tsx
page.tsx
layout.tsx
page.tsx
layout.tsx
page.tsxexport default function DashboardDefault() {
return <h1>Dashboard</h1>;
}
app/dashboard/default.tsx. It covers children at /dashboard/late. With it, the recorded build passes, and a reload shows Dashboard.
The staff want the stock on every dashboard address, reloaded or not. Replace this app/dashboard/@stock/default.tsx with one line.
export default function StockDefault() {
return <p>Stock is on the dashboard's first tab.</p>;
}
export { default } from "./page";
export { default } from "./page";. Now the default is the stock page itself. In the recorded version with this line in both defaults, a reload of /dashboard/late shows the stock and Today at the tea shop, as the click did.
With this layout, a member of staff never sees the sales, but the sales still reach their browser. Which file should check the role, so that they don't?
import { Suspense } from "react";
import { cookies } from "next/headers";
async function ForRole({ admin, staff }: { admin: React.ReactNode; staff: React.ReactNode }) {
const role = (await cookies()).get("role")?.value;
return role === "admin" ? admin : staff;
}
export default function DashboardLayout({ children, admin, staff }: LayoutProps<"/dashboard">) {
return (
<main>
{children}
<Suspense fallback={<p>Checking your role…</p>}>
<ForRole admin={admin} staff={staff} />
</Suspense>
</main>
);
}
import { getRole } from "@/app/role";
export default async function AdminPanel() {
if ((await getRole()) !== "admin") return null;
return <p>Sales today: $1,240</p>;
}
app/dashboard/@admin/page.tsx. Next.js renders every slot, so the layout's choice can't keep the sales private. With the check in the slot, the recorded RSC payload for staff no longer holds Sales today: $1,240.
Check your understanding
Question 1 of 7Which address does
app/dashboard/@orders/late/page.tsxmake?Choose an answer to see the explanation.
Question 2 of 7In this dashboard, which prop does
app/dashboard/page.tsxfill?Read the code, then predictTextapp/ dashboard/ @orders/ page.tsx @stock/ page.tsx layout.tsx page.tsx layout.tsx page.tsxChoose an answer to see the explanation.
Question 3 of 7A visitor on
/dashboardclicks Late orders. What does the stock slot show then?Choose an answer to see the explanation.
Question 4 of 7With Next.js 16.4.3, the stock slot has no
default.tsxand no page for/dashboard/late. What happens?Choose an answer to see the explanation.
Question 5 of 7Both
default.tsxfiles callnotFound(). A visitor reloads/dashboard/late. What do they get?Choose an answer to see the explanation.
Question 6 of 7The layout returns
adminonly when the role cookie isadmin. Does a member of staff receive the admin slot's output?Choose an answer to see the explanation.
Question 7 of 7Only the orders slot should show
Loading orders…while it waits. Where doesloading.tsxgo?Choose an answer to see the explanation.
Key takeaways
- A folder named
@somethingis a slot. Its pages render in the parent layout as the propsomething, and its name never shows in the address. childrenis a slot too, filled by thepage.tsxbeside the layout.- On a click, a slot with nothing for the new address keeps its last page. After a reload, it shows its
default.tsx. - In Next.js 16.4, the build stops when a slot,
childrenincluded, has neither a page nor adefault.tsxfor an address its siblings can show. - Each slot can have its own
layout.tsx,loading.tsxanderror.tsx. With Cache Components, the slots share one shell, and each streams in or fails on its own. - Next.js renders every slot and sends its result. Check who may see a slot inside the slot, not only in the layout.
Remember the one-liner.
An @ folder is one more page in the same layout: a click keeps it, a reload needs its default.tsx.
Coming next: Intercepting routes and modals. It puts a slot to work for a modal: a tea opens on top of the list, and a reload shows the tea's full page.