Skip to content
Chapter 35Lesson 4

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 a direct visit. But you have only seen it on a fast connection, where everything resolves before you can blink. Your users, on hotel Wi-Fi or a train, will not. While the detail’s data arrives, the slot flashes the placeholder text “Loading detail…” that the starter left for you.

This last lesson makes the surface feel right under a real network. The goal: 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. The list’s data has already resolved, so it stays put; only the detail streams, gated by the artificial 600 ms delay on getInvoice from when you wired the slots.

The detail mid-stream under a throttled network: the list stays resolved on the left while the detail panel shows its skeleton before swapping to content.

Each slot should get its own segment-level loading UI, owned by the file convention rather than a hand-written tag. You will build two skeletons over the shadcn <Skeleton> primitive — a row-count one for the list, a header-plus-body one for the detail — then drop a loading.tsx into each slot that renders the matching skeleton. That placement is the whole point: because each slot owns its own loading file, each gets its own Suspense boundary, and the two regions stream independently with no extra wiring. A single shared boundary at the segment would gate both regions on the slower one, leaving the list behind the detail’s 600 ms delay even though its data is already in hand.

Two traps catch inexperienced engineers, and both have a cheap defence. The first is believing you are streaming when you are actually waterfalling; fast localhost hides every sequencing mistake, so throttle the network in DevTools and watch. The second is a skeleton that does not match its content, so the layout jumps the moment real data arrives; shape each skeleton to mirror the element it stands in for and the swap becomes invisible. Streaming a slow sub-section inside a page — a “related invoices” panel below the detail, say — would earn its own explicit <Suspense> tag rather than a loading.tsx; you will see where that reach lives but not build it.

The list slot shows a six-row skeleton placeholder before its content resolves.
tested
The detail slot shows a four-block skeleton — heading, subtitle, separator, body — that mirrors the invoice detail before its content resolves.
tested
Navigating from /invoices to /invoices/inv_005 streams the detail slot while the list stays mounted.
untested
Navigating from /invoices/inv_005 to /invoices/inv_009 re-streams only the detail slot.
untested
Each skeleton’s shape mirrors its final content, so nothing on screen shifts when the placeholder swaps for real data.
untested

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 and build on the shadcn <Skeleton> primitive you copied in earlier: the animate-pulse rounded-md block you reach for over a spinner.

src/components/skeletons.tsx
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>
);

ListSkeleton drives its rows off a ROWS constant of stable string ids, the same pattern the provided invoices/loading.tsx uses, so the keys hold up if the list ever becomes dynamic. Six rows is deliberate: it fills the visible 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 one requirement the tests cannot reach. Each block’s height and width stands 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.

Now the two loading files. Each is a single default export rendering its skeleton, small enough to look like it does nothing.

src/app/invoices/@list/loading.tsx
import { ListSkeleton } from '@/components/skeletons';
const ListLoading = () => <ListSkeleton />;
export default ListLoading;
src/app/invoices/@detail/[id]/loading.tsx
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;

There is no <Suspense> tag anywhere in this lesson, yet both slots stream, because the loading.tsx file convention is the Suspense boundary. Place a loading.tsx in a segment and the App Router wraps that segment’s page.tsx in a <Suspense> whose fallback is your loading file. The wiring is the file’s location, not a wrapper you write. Each slot is its own segment with its own loading.tsx, so each gets its own boundary and the framework streams them in parallel.

Carry one division of labour out of this chapter: loading.tsx is the segment-level skeleton owner, and an explicit <Suspense> is the sub-segment one. Reach for the file convention first; it is the default, it is free, and it stays put as you move code around. Reach for an explicit <Suspense> 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 get its own <Suspense> so it streams while the invoice shows right away.

Run the lesson’s test suite:

Terminal window
pnpm test:lesson 4

It renders each skeleton and checks its container’s data-testid and pulse-block count: six rows for ListSkeleton, four for DetailSkeleton. 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 CI runs on every push:

Terminal window
pnpm verify

That 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 independent streaming and a shift-free swap only show up on a real connection. Open DevTools, go to the Network tab, set throttling to Slow 3G, then walk this list by hand:

Navigating to a detail URL shows the DetailSkeleton, then the content, while the list stays mounted the whole time.
untested
Navigating between two invoices re-streams only the detail slot — the list never flashes its skeleton again.
untested
Each skeleton’s shape mirrors its final content: nothing on screen jumps when the placeholder swaps for real data.
untested
With JavaScript disabled in DevTools, the list and detail still render server-side, and the “New invoice” link degrades to the full page.
untested

That last check is the proof the surface degrades gracefully: with no client JavaScript, the server still renders both slots and the modal link becomes a plain navigation.