| | |

Next.js 19 ▲ Instant Navigations — Stream, cache, or block route behaviors

⏱️ Estimated read: 13 min | |

Next.js 16.3 introduced a set of opt-in behaviors under the name Instant Navigations. The premise is simple: in a server-driven application, a navigation that awaits data on the server blocks the route update until the network roundtrip completes. The user clicks a link and sees nothing until the server responds. Instant Navigations gives the developer three explicit choices for every server-side await: Stream it behind a <Suspense> boundary so the shell arrives immediately, Cache it with 'use cache' so a previously rendered UI is reused, or Block the navigation deliberately with export const instant = false when the route should wait for the server before showing anything .

This chapter covers the three route behaviors, the Cache Components flag that enables them, the partial prefetching model that closes the client-to-server gap, and the tooling (instant() and Instant Insights) that verifies a route is actually instant.

Key point: Every server-side await in a route becomes a decision. Stream or Cache to make the navigation instant. Block to opt out. The choice is per route, and the development overlay reports slow navigations as errors until the developer makes the choice explicit .


Why Instant Navigations exists

The network roundtrip problem. A server-driven navigation follows a fixed sequence: the user clicks, the client sends a request, the server renders, the response arrives, and the page updates. The server-side rendering work and the network latency are additive. A client-driven SPA inverts the sequence: the shell updates immediately, and the data fills in afterward. The SPA feels faster even when the total work is the same, because the user sees feedback before the network completes .

The hidden caching problem. Before Cache Components, Next.js had implicit caching behaviors that were difficult to reason about. A fetch might be cached or not depending on the route configuration, and the developer could not always predict which. Cache Components makes the model explicit: data is dynamic by default, and caching is opt-in through 'use cache' . Instant Navigations builds on this model. The three choices—Stream, Cache, Block—are the three ways a route can handle the data it awaits.

The prefetching problem. Making the server non-blocking is only half the solution. The other half is the client-to-server gap. If the client has to wait for the network before it can even render the shell, the navigation still feels slow. Partial Prefetching solves this by prefetching a reusable shell per route, not a full page per link. Twenty links to /chat/[id] trigger one shell prefetch, not twenty page prefetches .


a. Stream: the Suspense boundary

Streaming is the default answer for data that is per-request and cannot be shared across users. The data access is wrapped in a <Suspense> boundary, and the static part of the route—the heading, the layout, the cached components—ships immediately. The dynamic part streams in when the data resolves.

// app/dashboard/page.tsx
import { Suspense } from 'react'

export default function Page() {
  return (
    <main>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>Loading your data…</p>}>
        <UserData />
      </Suspense>
    </main>
  )
}

async function UserData() {
  const session = (await cookies()).get('session')
  // ...
}

The <h1> is part of the static shell. The UserData component reads cookies(), which is runtime data, and the boundary ensures that the rest of the page does not wait for it . The boundary should be placed as close to the data access as possible. If the boundary is at the top of the page, the entire page streams behind a single fallback. If the boundary is around only the dynamic component, the static content ships immediately .

The loading.js file is a shorthand for a segment-level boundary. It wraps the segment’s {children} in one <Suspense> boundary. It is appropriate when every component in the segment reads the same request value and there is nothing static to render above it .


b. Cache: the 'use cache' directive

Caching is the answer for data that does not change per-request and can be reused across users. The component or data function is marked with 'use cache', and the rendered output is stored and reused on subsequent navigations.

// app/products/[slug]/page.tsx
async function ProductDetails({ slug }: { slug: string }) {
  'use cache'
  cacheLife('hours')
  cacheTag(`product:${slug}`)

  const product = await db.product.findUnique({ where: { slug } })
  return <div>{product.name}</div>
}

The cacheLife function sets the lifetime, and cacheTag provides the invalidation key. When a mutation changes the product, updateTag or revalidateTag invalidates the cache entry, and the next navigation recomputes it . The cached UI is reused across requests, so the navigation shows the previous result instantly while the cache is valid.

A plain 'use cache' stores the entry in memory by default. On a serverless platform, the entry is discarded when the instance is destroyed. For durable storage across instances and deployments, 'use cache: remote' with a configured cache handler is required .


c. Block: the instant = false opt-out

Blocking is the deliberate choice to let a route wait for the server before updating. The route segment exports instant = false, and the development overlay stops reporting the slow navigation as an error.

// app/blog/[slug]/page.tsx
export const instant = false

A blog post page might choose this because showing a loading skeleton for an article is worse than waiting a few hundred milliseconds for the full page. The content is the point, and a shell with a spinner adds noise . The Block option is not a performance technique. It is an explicit permission to delay the navigation, and it should be used narrowly. A parent layout that sets instant = false affects navigation into that part of the tree, while sibling navigations below it can still be validated .


d. Partial Prefetching and the instant() test

Partial Prefetching changes the prefetching unit from the link to the route. A normal <Link> prefetches the route’s reusable shell by default. The shell contains the static UI and the cached UI that does not depend on the specific URL. Dynamic work stays behind <Suspense> and streams after the click .

A link that needs URL-specific cached content can opt into deeper prefetching with prefetch={true}. This resolves the URL data (params, searchParams) before navigation and includes cached content that depends on those values. It does not download the uncached dynamic parts; those still stream after the click .

The instant() helper from @next/playwright is the verification tool. It pauses the navigation at the shell stage, so the test can assert what is visible without waiting for the network.

import { test, expect } from '@playwright/test'
import { instant } from '@next/playwright'

test('product title is available immediately', async ({ page }) => {
  await page.goto('/products/shoes')
  await instant(page, async () => {
    await page.click('a[href="/products/hats"]')
    await expect(page.locator('h1')).toContainText('Baseball Cap')
    await expect(page.getByText('Checking inventory...')).toBeVisible()
  })
})

Inside the instant() callback, the assertions must pass without waiting for the server. The content after the callback is the content that streams in later . The test runs against next dev automatically, and against a production build when exposeTestingApiInProductionBuild is enabled .


Complete Example Session

// ============================================
// PART 1: ENABLE CACHE COMPONENTS
// ============================================
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
}

export default nextConfig
// ============================================
// PART 2: THE STREAM PATTERN
// ============================================
// app/dashboard/page.tsx
import { Suspense } from 'react'

export default function Page() {
  return (
    <main>
      <h1>Dashboard</h1>
      <Suspense fallback={<DashboardSkeleton />}>
        <UserStats />
      </Suspense>
    </main>
  )
}
// ============================================
// PART 3: THE CACHE PATTERN
// ============================================
// app/products/[slug]/page.tsx
import { cacheLife, cacheTag } from 'next/cache'

async function ProductDetails({ slug }: { slug: string }) {
  'use cache'
  cacheLife('hours')
  cacheTag(`product:${slug}`)
  const product = await db.product.findUnique({ where: { slug } })
  return <div>{product.name}</div>
}
// ============================================
// PART 4: THE BLOCK PATTERN
// ============================================
// app/blog/[slug]/page.tsx
export const instant = false

export default async function PostPage({ params }) {
  const post = await getPost((await params).slug)
  return <article>{post.content}</article>
}
// ============================================
// PART 5: THE LOADING.JS SHORTHAND
// ============================================
// app/dashboard/loading.tsx
export default function Loading() {
  return <DashboardSkeleton />
}

// Wraps the segment's children in one Suspense boundary.
// ============================================
// PART 6: THE INSTANT TEST
// ============================================
import { test, expect } from '@playwright/test'
import { instant } from '@next/playwright'

test('dashboard heading is instant', async ({ page }) => {
  await page.goto('/')
  await instant(page, async () => {
    await page.click('a[href="/dashboard"]')
    await expect(page.locator('h1')).toContainText('Dashboard')
  })
})
// ============================================
// PART 7: THE PER-LINK PREFETCH
// ============================================
<Link href={`/products/${product.slug}`} prefetch={true}>
  {product.name}
</Link>

// Resolves URL data and includes cached content
// that depends on params.
// ============================================
// PART 8: THE MUTATION INVALIDATION
// ============================================
'use server'
import { updateTag } from 'next/cache'

export async function updateProduct(slug: string, data: ProductData) {
  await db.product.update({ where: { slug }, data })
  updateTag(`product:${slug}`)
}
// ============================================
// PART 9: THE STATIC SHELL REFACTOR
// ============================================
// Before: everything awaits at the top
export default async function Page({ searchParams }) {
  const { q } = await searchParams
  const results = await search(q)
  return <Results data={results} />
}

// After: the shell renders immediately
export default function Page({ searchParams }) {
  return (
    <Suspense fallback={<ResultsSkeleton />}>
      <Results searchParams={searchParams} />
    </Suspense>
  )
}
// ============================================
// PART 10: THE DECISION TREE
// ============================================
// Is the data per-request and not shareable?
//   Stream with <Suspense>
//
// Is the data shareable and cacheable?
//   Cache with 'use cache'
//
// Should the route wait for the server?
//   Block with export const instant = false

The ten parts covered the config, the Stream pattern, the Cache pattern, the Block pattern, the loading.js shorthand, the instant() test, the per-link prefetch, the mutation invalidation, the static shell refactor, and the decision tree.


Quick Reference

The three behaviors

BehaviorDeclarationWhat the user seesUse case
Stream<Suspense fallback>Instant loading shell, content streams inPer-request data
Cache'use cache'Instant previously cached UIShareable, slow-changing data
Blockexport const instant = falseWaits for server, then full pageContent pages that should not show a shell

Configuration flags

FlagPurpose
cacheComponents: trueEnables the Stream/Cache/Block model
partialPrefetching: trueEnables per-route shell prefetching

Prefetching

MechanismEffect
Default <Link>Prefetches the route’s reusable shell
<Link prefetch={true}>Resolves URL data, includes cached content for that URL

Tooling

ToolPurpose
Instant InsightsReports slow navigations as development errors
Navigation InspectorPauses a navigation at the shell stage
instant()Playwright helper for asserting what is visible without the network

Best Practices

✅ Do This:

// Put the Suspense boundary close to the data access
<Suspense fallback={<UserSkeleton />}>
  <UserHeader />
</Suspense>                                                  // ✅
// Use 'use cache' for shareable data
'use cache'
cacheLife('hours')
cacheTag('products')                                         // ✅
// Use instant = false only for routes that should block
export const instant = false                                 // ✅

❌ Don’t Do This:

// Wrap the entire page in one Suspense boundary
<Suspense fallback={<FullPageSkeleton />}>
  <Page />
</Suspense>                                                  // ❌ (no granularity)
// Use plain 'use cache' for per-user data on serverless
// Use 'use cache: private' or extract the user ID          // ❌
// Set instant = false on a layout to silence errors
export const instant = false  // in layout.tsx              // ❌ (affects the whole subtree)

Common Pitfalls

PitfallWhy It HappensFix
Navigation is not instantData awaits outside a <Suspense> boundaryWrap the dynamic component in <Suspense>
Cached UI is stalecacheTag not invalidated after mutationCall updateTag or revalidateTag in the action
instant() test failsDynamic content is visible before the networkMove the dynamic content behind a boundary
Per-link prefetch fetches too muchprefetch={true} on every linkUse it only for links that need URL-specific cached content
Blocking route still reports an errorinstant = false placed in a parent layoutPlace it in the page segment

Real-World Examples

1. Stream with Suspense

<Suspense fallback={<StatsSkeleton />}>
  <Stats />
</Suspense>

2. Cache with use cache

'use cache'
cacheLife('hours')

3. Block a route

export const instant = false

4. loading.js shorthand

// app/dashboard/loading.tsx
export default function Loading() { return <Skeleton /> }

5. Per-link prefetch

<Link href={`/products/${slug}`} prefetch={true}>

6. instant() test

await instant(page, async () => {
  await page.click('a[href="/dashboard"]')
  await expect(page.locator('h1')).toContainText('Dashboard')
})

7. Cache invalidation

updateTag(`product:${slug}`)

8. The config

cacheComponents: true, partialPrefetching: true

9. The refactor

// Move URL-dependent work into a Suspense boundary
<Suspense fallback={<ResultsSkeleton />}>
  <Results searchParams={searchParams} />
</Suspense>

10. The decision

Per-request data? → Stream
Shareable data? → Cache
Should wait? → Block

Visual

The three choices

┌─────────────────────────────────────────────────────────────┐
│  await data                                                  │
│       │                                                      │
│       ├── Stream ──► <Suspense> ──► shell now, content later│
│       │                                                      │
│       ├── Cache ──► 'use cache' ──► cached UI now           │
│       │                                                      │
│       └── Block ──► instant = false ──► wait for server     │
└─────────────────────────────────────────────────────────────┘

The static shell

┌─────────────────────────────────────────────────────────────┐
│  Without Suspense:                                           │
│    Everything waits for the data → slow navigation           │
│                                                              │
│  With Suspense:                                              │
│    Static shell ships ──► dynamic content streams           │
└─────────────────────────────────────────────────────────────┘

Partial Prefetching

┌─────────────────────────────────────────────────────────────┐
│  Twenty links to /chat/[id]:                                 │
│    Before: twenty prefetch requests                          │
│    After: one shell for /chat/[id], reused                   │
│                                                              │
│  <Link prefetch={true}>: deeper prefetch for one link       │
└─────────────────────────────────────────────────────────────┘

The instant() test

┌─────────────────────────────────────────────────────────────┐
│  instant(page, async () => {                                 │
│    // assertions here must pass without the network          │
│  })                                                          │
│                                                              │
│  Outside the callback: content that streams in later         │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
Feature nameInstant Navigations
Introduced inNext.js 16.3
Required flagcacheComponents: true
Prefetching flagpartialPrefetching: true
Stream declaration<Suspense fallback={...}>
Cache declaration'use cache'
Block declarationexport const instant = false
Test helperinstant() from @next/playwright
Dev toolInstant Insights

Key takeaways:

  • Every server-side await becomes a Stream, Cache, or Block decision. The Cache Components flag enables the model. The development overlay reports slow navigations as errors until the developer makes the choice explicit .
  • Stream is for per-request data that cannot be shared. The <Suspense> boundary ships the static shell immediately and streams the dynamic content when it resolves. Place the boundary close to the data access for granularity .
  • Cache is for shareable data that changes slowly. The 'use cache' directive stores the rendered output and reuses it across requests. cacheLife sets the lifetime, and cacheTag provides the invalidation key .
  • Block is the deliberate opt-out. export const instant = false tells Next.js that the route should wait for the server before updating. It is appropriate for content pages where a loading skeleton is worse than a short wait .
  • Partial Prefetching changes the unit from the link to the route. A normal <Link> prefetches the route’s reusable shell. <Link prefetch={true}> resolves URL data for a specific link and includes cached content that depends on it .
  • The instant() helper verifies that a route is actually instant. It pauses the navigation at the shell stage, so the test asserts what is visible without the network. The test runs in CI to prevent regressions .

Remember: Instant Navigations is not a single feature. It is a discipline. Every route that awaits data must declare whether it streams, caches, or blocks. Stream and Cache make the navigation feel like an SPA. Block is the explicit choice to remain server-bound. The instant() test proves that the choice works. Enable cacheComponents and partialPrefetching, fix the errors that Instant Insights surfaces, and keep the tests in CI. The result is a server-driven application with client-driven navigation speed.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!