Why loading.tsx betrays you
Your loading.tsx and top-level boundaries now block the shell. The two-navigation validation model, the top-level await trap, and how to lock instant nav in.
You know Suspense. The placement rules changed.
Instant navigation reuses primitives you already have, Suspense, streaming, prefetch. What is new is a validation model that judges each boundary against two different navigations, and it turns some habits, especially loading.tsx, into anti-patterns.
A loading.tsx is a Suspense boundary at the top of the segment. Under Cache Components that boundary sits above your static and cached content and pulls all of it out of the shell, so the user gets a full-page skeleton instead of a shell. Push boundaries down to the single read they guard instead.
The habit and the fix
The whole-page skeleton was the point of loading.tsx. Now it is the failure mode: nothing static or cached can reach the first paint through it. Delete it, render the shell content directly, and wrap only the request read.
// app/dashboard/loading.tsx// You wrote this for years. It is a Suspense boundary at the TOP of the segment.export default function Loading() { return <WholePageSkeleton />}// app/dashboard/page.tsx (no loading.tsx)export default function DashboardPage() { return ( <Layout> <Header /> {/* static → shell */} <Stats /> {/* 'use cache' → shell */} <Suspense fallback={<NotificationsSkeleton />}> <Notifications /> {/* reads cookies() → the only thing that streams */} </Suspense> </Layout> )}If an element appears in both the fallback and the resolved result, hoist it above the boundary. A skeleton should stand in for data, not for your layout. And keep the largest element, usually the h1, out of every boundary so it paints at once.
The rule that hand-review misses: two navigations
The same route produces different first paints depending on how the user arrives, and a boundary can cover one path while leaving the other blocking. This is the core reason the framework validates for you.
| Arrival | What re-renders |
|---|---|
| Page load (direct visit / refresh) | The full tree renders from the root. The static shell ships as HTML, often from a CDN. |
| Client navigation (in-app link click) | Only the parts below the layout the two routes share re-render. Everything above is already mounted. |
So a boundary in the root layout covers a page load, but on a client navigation between two siblings it sits above the re-render scope and never triggers. A boundary must live below the lowest layout the start and target routes share.
A direct visit to /store/hats is instant, but clicking from /store/shoes to /store/hats blocks. The boundary is in the root layout. Why the difference?
useSearchParams() suspends on a page load (params unknown at build) but resolves synchronously on a client navigation (the router already has them). The same component blocks on one path and is instant on the other, which is exactly the kind of asymmetry the two-navigation check exists to catch.
The frequent blocker: a top-level await
The most common cause of a blocked shell is a top-level await in a layout or page, an auth gate being the classic. It runs before any child renders, holding the whole subtree out of the shell.
// The classic blocker: a top-level await in a layout.export default async function Layout({ children }) { const user = await getSession() // request read, runs before any child if (!user) redirect('/login') return <Shell>{children}</Shell> // whole subtree held out of the shell}The mechanical fix is to move the read into a Suspense-wrapped child, but wrapping an auth gate can change what it guarantees. If the gate is load-bearing, move it to Proxy or a Data Access Layer, or keep the route instant = false on purpose. Fix pages too: a page-level await params blocks the same way.
Validation does the audit; then you lock it in
By default (validationLevel: 'warning') Cache Components validates every Page and Default segment in next dev, simulating both arrivals on each load. Insights land in the dev overlay, not the build, so a clean next build does not mean instant. Each insight offers three fixes.
| Card | What it does |
|---|---|
| Stream | Wrap the read in Suspense so it streams behind a fallback. |
| Cache | Add 'use cache' so the result joins the shell. |
| Block | Set instant = false to allow the route to block on purpose. |
Validation only proves a shell exists, not that the right content is in it (a boundary around the whole body passes with an empty shell). Lock the real content in with the instant() helper from @next/playwright, which scopes assertions to the UI present the moment navigation starts.
import { test, expect } from '@playwright/test'import { instant } from '@next/playwright' test('product page is instant on a client navigation', async ({ page }) => { await page.goto('/store/shoes') await instant(page, async () => { await page.click('a[href="/store/hats"]') await page.waitForURL((url) => url.pathname === '/store/hats') // wait for commit await expect(page.locator('h1')).toContainText('Baseball Cap') // in the shell await expect(page.getByText('In stock')).toHaveCount(0) // NOT in the shell }) await expect(page.getByText('In stock')).toBeVisible() // streamed after})The Navigation Inspector in Next.js DevTools freezes the shell so you can see exactly what lands. To validate only segments that opt in, set validationLevel: 'manual-warning'; to run instant() against a production build, set exposeTestingApiInProductionBuild. (Instant assumes a warm cache; the first, cold navigation may still wait.)
A product page has a large title, a cached description, and a per-user 'recently viewed' strip. You currently ship a loading.tsx full-page skeleton. What is the correct structure?
What you learned
loading.tsxand top-level boundaries now pull content out of the shell.- Page load renders the whole tree; client navigation renders only below the shared layout.
- A boundary must sit below the lowest layout the two routes share.
- A top-level
awaitblocks the subtree; move it or document a Block. - Dev validation finds the gaps; an
instant()test proves the content.
Next: prefetching. The behavior of <Link> and prefetch={true} changed, and the old meaning can quietly regress your app.