Independent streaming per slot
The surface works. The list filters server-side, the detail loads, the modal opens on soft navigation and falls back to a full page on direct visit. But you have only ever seen it on a fast connection, where everything resolves before you can blink. Your users will not. On a hotel Wi-Fi or a train, the detail’s data takes a moment to arrive — and right now, while it does, the slot flashes the text “Loading detail…”, a stub left in the starter waiting for you.
This last lesson makes the surface feel right under a real network. The goal, in user terms: under a throttled connection, opening or switching an invoice keeps the list exactly where it is while the detail panel streams from a skeleton to its content on its own. The list’s data has already resolved, so it stays put; only the detail — gated by the artificial 600 ms delay on getInvoice you met when you wired the slots — visibly streams.
Your mission
Section titled “Your mission”Each slot should get its own segment-level loading UI, so the seam where streaming kicks in is owned by the file convention rather than a hand-written tag. You will build two skeleton components over the shadcn <Skeleton> primitive — a row-count one for the list, a header-plus-body one for the detail — and then drop a loading.tsx into each slot that renders the matching skeleton. That loading.tsx placement is the whole point of the lesson: because each slot owns its own loading file, each gets its own Suspense boundary, and the two regions stream independently with zero extra wiring. A single shared boundary up at the segment would gate both regions on the slower one — the list would sit behind the detail’s 600 ms delay even though its data is already in hand.
There are two traps inexperienced engineers fall into here, and both have a cheap defence. The first is believing you are streaming when you are actually waterfalling; the only way to know is to throttle the network in DevTools and watch — fast localhost hides every sequencing mistake. The second is a skeleton that does not match its content, so the moment real data arrives the layout jumps. Shape each skeleton to mirror the final element it stands in for, and the swap from placeholder to content becomes invisible. One thing stays out of scope: streaming a slow sub-section inside a page — say a “related invoices” panel below the detail — would earn its own explicit <Suspense> tag rather than a loading.tsx. You will see where that reach lives, but you will not build it.
/invoices to /invoices/inv_005 streams the detail slot while the list stays mounted./invoices/inv_005 to /invoices/inv_009 re-streams only the detail slot.Coding time
Section titled “Coding time”Implement the three files against the brief and the test suite, then open the walkthrough to compare.
Reference solution and walkthrough
Start with the skeletons, since the loading.tsx files are one-liners that render them. Both live in components/skeletons.tsx, both build on the shadcn <Skeleton> primitive you copied into the repo back in the lesson “Four states, not one” — the animate-pulse rounded-md block that the chapter on the shadcn accessibility baseline taught you to reach for over a spinner.
import { Skeleton } from '@/components/ui/skeleton';
const ROWS = ['r1', 'r2', 'r3', 'r4', 'r5', 'r6'] as const;
export const ListSkeleton = () => ( <div data-testid="list-skeleton" className="flex flex-col gap-1 p-2"> {ROWS.map((row) => ( <Skeleton key={row} className="h-12 w-full" /> ))} </div>);
export const DetailSkeleton = () => ( <div data-testid="detail-skeleton" className="flex flex-col gap-4 p-6"> <Skeleton className="h-8 w-48" /> <Skeleton className="h-4 w-32" /> <Skeleton className="h-px w-full" /> <Skeleton className="h-40 w-full" /> </div>);Two details in ListSkeleton are worth pausing on. The rows are driven by a ROWS constant of stable string ids rather than Array.from({ length: 6 }) mapped over its index. React uses the key to track which element is which across renders, and an array index makes a poor key the moment the list can reorder. This list never reorders, so the index would technically be safe — but reaching for stable keys by reflex, and matching the pattern the provided invoices/loading.tsx already uses, is the habit that saves you when a list does become dynamic. Six rows is a deliberate count: it fills the visible list height so the placeholder reads as “a list is coming,” not “one item is coming.”
DetailSkeleton is where the “mirror the content” rule earns its place, and it is the requirement the tests cannot reach. Each block’s height and width is chosen to stand in for a specific element of the real InvoiceDetail, so when the 600 ms delay resolves and the article replaces the skeleton, nothing on screen moves:
<div data-testid="detail-skeleton" className="flex flex-col gap-4 p-6"> <Skeleton className="h-8 w-48" /> {/* → number heading */} <Skeleton className="h-4 w-32" /> {/* → customer subtitle */} <Skeleton className="h-px w-full" />{/* → <Separator /> */} <Skeleton className="h-40 w-full" />{/* → the details <dl> */}</div>Four blocks, one per element. The h-8 heading bar stands in for the text-2xl number h1, the h-4 bar for the customer subtitle, the h-px bar matches the <Separator />’s exact height, and the h-40 block covers the status/amount/due-date dl.
<article data-testid="invoice-detail" className="flex flex-col gap-4 p-6"> <header className="flex flex-col gap-1"> <h1 className="text-2xl font-semibold tracking-tight"> {invoice.number} </h1> <p className="text-sm text-muted-foreground">{invoice.customer}</p> </header>
<Separator />
<dl className="grid grid-cols-[8rem_1fr] gap-y-3 text-sm"> {/* status, amount, due date rows */} </dl></article>The content the skeleton mirrors. Note the shared flex flex-col gap-4 p-6 wrapper — same wrapper, same vertical rhythm as the skeleton, so when the 600 ms delay resolves and the article replaces the placeholder, nothing on screen moves.
Now the two loading files. Each is a single default export rendering its skeleton — and this is the part that should look almost too small to be doing anything.
import { ListSkeleton } from '@/components/skeletons';
const ListLoading = () => <ListSkeleton />;
export default ListLoading;import { DetailSkeleton } from '@/components/skeletons';
// A slow related panel inside the detail would get its own explicit <Suspense> (Ch 031); this loading.tsx is the whole-slot seam.const DetailLoading = () => <DetailSkeleton />;
export default DetailLoading;Here is the thing to notice: there is no <Suspense> tag anywhere in this lesson’s code, yet both slots stream. That is not magic — the loading.tsx file convention is the Suspense boundary. When you place a loading.tsx in a segment, the App Router wraps that segment’s page.tsx in a <Suspense> whose fallback is your loading file, automatically. The wiring is the file’s location, not a wrapper you write. Because each slot is its own segment with its own loading.tsx, each gets its own boundary, and the framework streams them in parallel.
That is the division of labour worth carrying out of this chapter: loading.tsx is the segment-level skeleton owner, and an explicit <Suspense> is the sub-segment one. You reach for the file convention first — it is the default, it is free, and it stays put as you move code around. You reach for the explicit <Suspense> tag only when you need a boundary inside a segment, around one slow piece of a page while the rest renders immediately. The comment in @detail/[id]/loading.tsx marks exactly that reach: a slow “related invoices” panel below the detail would be wrapped in its own <Suspense> so it could stream while the invoice itself shows right away. That pattern — one Suspense boundary per independent read inside a page — belongs to the lesson “Streaming a page in chunks”, and you are deliberately not building it here.
If you want to revisit the mechanics underneath all of this, the lesson “Suspense, the fallback contract” covers <Suspense> as the streaming seam and “The three segment files” covers loading.tsx at the segment. This lesson is the application, not the re-teach.
The exact convention you drop into each slot, and why it is a Suspense boundary.
Why slots stream independently, each with its own loading state.
loading.js vs an explicit <Suspense>, plus the Network-tab check behind your throttle test.
The pulse primitive both ListSkeleton and DetailSkeleton build on.
With these three files in place, the chapter is done. There are a few directions this same surface grows in later. In the unit on forms and Server Actions, the modal form gets a real submit — <form action={createInvoice}> — so “New invoice” actually writes a record. Before that, the unit on Postgres and Drizzle swaps the in-memory fixture you have been reading from for a real database, with getInvoice becoming an actual query instead of a fixture lookup with a fake delay. And much later, the unit on lists and URL state returns to this exact list-plus-detail view to add nuqs, cursor pagination, sorting, and soft delete on top of the ?status= filter you already built. The shape you shipped here is the foundation all of that lands on.
Moment of truth
Section titled “Moment of truth”Run the lesson’s test suite:
pnpm test:lesson 4It renders each skeleton to its first-paint markup and asserts the structure your slots stream under — that ListSkeleton carries data-testid="list-skeleton" and draws exactly six pulse blocks, and that DetailSkeleton carries data-testid="detail-skeleton" and draws exactly four. A clean run looks like this:
✓ tests/lessons/Lesson 4.test.ts (4 tests) 12ms ✓ List slot shows a six-row ListSkeleton placeholder ✓ renders the list-skeleton container so the loading slot is identifiable ✓ draws six placeholder rows that mirror the invoice list ✓ Detail slot shows a DetailSkeleton that mirrors the invoice detail ✓ renders the detail-skeleton container so the loading slot is identifiable ✓ draws four placeholder blocks mirroring heading, subtitle, separator and body
Test Files 1 passed (1) Tests 4 passed (4)Then run the full project gate, which is what CI runs on every push:
pnpm verifyThat is Biome’s CI check, next typegen, tsc --noEmit, and a production build, all clean. No TODO(L4) markers should remain.
The tests confirm the skeletons’ shape, but the behaviours that actually motivated this lesson — independent streaming and a shift-free swap — only show up on a real connection. Open DevTools, go to the Network tab, and set throttling to Slow 3G, then walk this list by hand:
DetailSkeleton, then the content, while the list stays mounted the whole time.That last check is the proof the whole surface degrades gracefully — with no client JavaScript at all, the server still renders both slots, and the modal link becomes an ordinary navigation to the full-page form.
This is the surface complete. Every goal the chapter set out is now something you can confirm in the browser: server-side filtering that survives a hard reload, a modal that opens on soft navigation and falls back to the full page on a direct visit, a refresh, or a Cmd+click, and per-slot streaming where the list holds steady while only the detail re-streams. You started this chapter from a degit snapshot and ended it with the canonical SaaS list-plus-detail workspace running on real App Router primitives.