How to Fix: Suspense stops working when navigating again with next/link in PRODUCTION

7 min read

Suspense appears to work on the first visit, then silently stops showing its fallback after navigating away and back with next/link in production because the route is being served from the App Router cache. In this scenario, the second navigation often reuses an already-resolved React Server Component payload, so there is no new async boundary suspension to trigger the loading UI again.

Understanding the Root Cause

This issue is most visible in Next.js App Router projects using Suspense, a client page or async server component, and navigation through Link. It tends to reproduce only in production builds because development mode behaves differently: dev disables or relaxes several caching and prefetch optimizations to improve iteration speed.

When you click a next/link, Next.js may prefetch the target route in the background. If that route has already been fetched and its server payload is cached, the next visit can be fulfilled immediately from memory. Since the data is already resolved, the Suspense boundary does not suspend again, so the fallback never appears.

Technically, the behavior is usually caused by a combination of these factors:

  • Route prefetching loads the page before the user actually navigates.
  • RSC payload caching lets Next.js reuse the rendered server result on subsequent navigations.
  • Static rendering or cached fetches make the async work effectively instant after the first request.
  • Client-side transitions with Link preserve app state and reuse cached segments more aggressively than a full page refresh.

That means the problem is usually not that Suspense is broken. The real issue is that there is nothing left to suspend on after the route has been prefetched or cached.

If your goal is to show a loading state on every navigation, you need to force the route, component, or data request to become dynamic again, or you need to move the loading UX to a mechanism designed for route transitions such as loading.js.

Step-by-Step Solution

The right fix depends on what you want:

  • If you want a loading UI during route transitions, use loading.js.
  • If you want Suspense to re-trigger because data should be fetched again, disable caching for the relevant data or route.
  • If prefetching is masking the fallback, disable prefetch on the link for that navigation path.

1. Use loading.js for route-level loading states

In the App Router, the most reliable way to show loading during navigation is to add a loading.tsx or loading.jsx file in the route segment.

app/suspense-page/loading.tsx
export default function Loading() {
  return <p>Loading route...</p>
}

This file is specifically designed for navigation transitions and works better than expecting a nested Suspense fallback to appear on every cached revisit.

2. Make the page or data request dynamic

If the page should fetch fresh data every time, mark the route as dynamic or disable caching on the request.

app/suspense-page/page.tsx
export const dynamic = 'force-dynamic'

import { Suspense } from 'react'
import DataView from './DataView'

export default function Page() {
  return (
    <Suspense fallback={<p>Loading suspense content...</p>}>
      <DataView />
    </Suspense>
  )
}

And inside the async component or data layer:

app/suspense-page/DataView.tsx
async function getData() {
  const res = await fetch('https://example.com/api/data', {
    cache: 'no-store'
  })

  if (!res.ok) {
    throw new Error('Failed to fetch data')
  }

  return res.json()
}

export default async function DataView() {
  const data = await getData()
  return <div>{data.message}</div>
}

Using cache: ‘no-store’ or dynamic = ‘force-dynamic’ ensures the route is not satisfied from a stale cached result, which allows the async boundary to suspend again.

If the issue happens because the route was already prefetched before the click, disable prefetching for that specific link.

import Link from 'next/link'

export default function Home() {
  return (
    <Link href="/suspense-page" prefetch={false}>
      Open suspense page
    </Link>
  )
}

This can make the navigation wait for the actual transition request instead of reusing a prefetched payload.

4. Add an artificial async boundary only for debugging

If you are validating that Suspense is wired correctly, use a controlled delay in a server component. This is useful for reproduction testing, not as a production fix.

async function wait(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

export default async function DataView() {
  await wait(2000)
  return <div>Loaded after delay</div>
}

If this fallback appears only on the first visit, your issue is almost certainly related to prefetching or route caching, not Suspense syntax.

5. Prefer route-level loading for navigation UX, Suspense for component-level streaming

A strong production pattern is:

  • Use loading.js for route transitions.
  • Use nested Suspense for slower subtrees inside the page.
  • Use no-store or dynamic rendering only where fresh data is actually required.

This keeps performance benefits from caching while still giving users clear loading feedback.

Example final structure

app/
  page.tsx
  suspense-page/
    loading.tsx
    page.tsx
    DataView.tsx
// app/suspense-page/loading.tsx
export default function Loading() {
  return <p>Loading page...</p>
}
// app/suspense-page/page.tsx
import { Suspense } from 'react'
import DataView from './DataView'

export const dynamic = 'force-dynamic'

export default function Page() {
  return (
    <main>
      <h1>Suspense demo</h1>
      <Suspense fallback={<p>Loading section...</p>}>
        <DataView />
      </Suspense>
    </main>
  )
}
// app/suspense-page/DataView.tsx
async function getData() {
  const res = await fetch('https://example.com/api/data', { cache: 'no-store' })
  if (!res.ok) throw new Error('Request failed')
  return res.json()
}

export default async function DataView() {
  const data = await getData()
  return <pre>{JSON.stringify(data, null, 2)}</pre>
}
// app/page.tsx
import Link from 'next/link'

export default function Home() {
  return (
    <div>
      <Link href="/suspense-page" prefetch={false}>
        Go to suspense page
      </Link>
    </div>
  )
}

Common Edge Cases

  • It works in development but fails in production: this is expected for many caching-related App Router issues. Always test with next build and next start.
  • Adding Suspense around a client component does nothing: Suspense only shows a fallback when something inside actually suspends. A normal client component without lazy loading or async resources will render immediately.
  • fetch() still seems cached: verify that every relevant request uses cache: ‘no-store’ or a suitable revalidate strategy. A parent route or wrapped data helper may still be caching.
  • Prefetch is still happening indirectly: some routes can be warmed by user behavior or nearby viewport links. Test with a minimal reproduction and explicit prefetch={false}.
  • loading.js shows but nested Suspense does not: this usually means the route transition is pending, but the inner component resolved from cache too quickly to suspend.
  • Static optimization overrides expectations: if the page can be statically rendered, Next.js may optimize it unless you opt into dynamic behavior.

FAQ

Because the first visit performs real async work, while the second visit often reuses a prefetched or cached route payload. Without a pending async operation, the fallback is never shown.

Is this a Next.js bug or expected App Router behavior?

In most cases, this is expected behavior from App Router caching, route prefetching, and React Server Component reuse in production. It can feel like a bug if you expect Suspense to act as a transition loader on every visit, but that is better handled by loading.js.

What is the safest production fix?

The safest fix is to use loading.js for route navigation feedback and only disable caching where fresh data is genuinely required. If prefetching specifically causes the problem, add prefetch={false} to the relevant Link.

For the original reproduction, the practical resolution is to stop relying on nested Suspense as proof of every navigation transition. Instead, combine loading.js, selective dynamic rendering, and targeted prefetch control so production behavior matches the user experience you actually want.

Leave a Reply

Your email address will not be published. Required fields are marked *