How to Fix: Dynamic Layout with Suspense hangs after 2nd navigation in Next 15

7 min read

Next 15 dynamic layout + Suspense can freeze after the second navigation because the layout is being cached while its async boundary never gets a fresh invalidation signal.

This bug shows up when a dynamic layout contains a Suspense boundary and the App Router reuses the previously cached layout tree across navigations. The first transition works, the second often hangs, and the UI appears stuck because React is waiting on a server-rendered segment that Next.js does not fully recompute.

Understanding the Root Cause

In Next 15, layouts are persistent by design. Unlike pages, a layout is intended to survive route transitions so that shared UI does not remount on every navigation. That behavior is usually a performance win, but it becomes problematic when all of the following are true:

  • The layout is treated as dynamic.
  • The layout contains a Suspense boundary that depends on server-side async work.
  • Navigation changes child segments, but the parent layout instance is still reused from cache.

When that happens, the router may preserve the existing layout subtree while React expects a fresh async resolution for the suspended content. After the second navigation, the cached layout and the new route state can fall out of sync. The result is a transition that never fully resolves, which looks like a hang.

At a lower level, this is a coordination problem between:

  • App Router segment caching
  • React Server Components payload reuse
  • Suspense boundary resolution inside a persistent layout

If the data or route state that drives the layout is not reflected in a remount or explicit cache invalidation, the layout can keep rendering a stale async boundary. The page beneath it changes, but the layout-level suspense state does not reliably reset.

That is why this issue is most reproducible when the async work lives in layout.tsx rather than in a page or a smaller nested component keyed by the route.

Step-by-Step Solution

The safest fix is to move route-sensitive async rendering out of the persistent layout or force the suspended subtree to remount when navigation changes.

Use one of these approaches, in this order of preference.

1. Keep the layout static and move Suspense to a nested boundary

If your layout contains async logic that depends on the current route, move that logic into a child server component or page-level wrapper. Let the layout remain structural only.

// app/[slug]/layout.tsx
export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div>
      <header>Shared layout</header>
      {children}
    </div>
  )
}
// app/[slug]/page.tsx
import { Suspense } from 'react'
import RouteAwareContent from './route-aware-content'

export default function Page({ params }: { params: { slug: string } }) {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <RouteAwareContent slug={params.slug} />
    </Suspense>
  )
}
// app/[slug]/route-aware-content.tsx
export default async function RouteAwareContent({ slug }: { slug: string }) {
  const data = await fetch(`https://example.com/api/${slug}`, {
    cache: 'no-store',
  }).then((r) => r.json())

  return <div>{data.title}</div>
}

This works because the page segment naturally changes on navigation, so the suspense lifecycle is recreated correctly.

2. Key the route-sensitive subtree so it remounts on navigation

If the async boundary must stay near the layout, wrap only the route-dependent subtree in a component that receives a changing key.

// app/[slug]/layout.tsx
import { Suspense } from 'react'

export default function Layout({
  children,
  params,
}: {
  children: React.ReactNode
  params: { slug: string }
}) {
  return (
    <div>
      <header>Shared layout</header>
      <Suspense key={params.slug} fallback={<div>Loading layout content...</div>}>
        {children}
      </Suspense>
    </div>
  )
}

This is not always the ideal architecture, but it can break the stale cache relationship by forcing a fresh render for that subtree.

3. Remove dynamic behavior from the layout if it is not essential

If the layout was made dynamic only because of one fetch or one call to a dynamic API, consider removing that dependency. In many cases, a layout does not need to be dynamic at all.

// Avoid route-sensitive async work in layout when possible
export const dynamic = 'force-static'

Only do this if the layout content can actually be static. Do not force static rendering if you rely on cookies, headers, auth state, or per-request data.

4. If fetching in the layout is unavoidable, make the boundary explicit and isolated

Split the dynamic portion into a dedicated server component and make the remount condition clear.

// app/[slug]/layout.tsx
import { Suspense } from 'react'
import LayoutData from './layout-data'

export default function Layout({
  children,
  params,
}: {
  children: React.ReactNode
  params: { slug: string }
}) {
  return (
    <div>
      <header>
        <Suspense key={params.slug} fallback={<div>Loading header...</div>}>
          <LayoutData slug={params.slug} />
        </Suspense>
      </header>
      {children}
    </div>
  )
}
// app/[slug]/layout-data.tsx
export default async function LayoutData({ slug }: { slug: string }) {
  const data = await fetch(`https://example.com/api/layout/${slug}`, {
    cache: 'no-store',
  }).then((r) => r.json())

  return <div>{data.name}</div>
}

This pattern limits the risky part of the tree and makes stale state easier to reason about.

5. Verify navigation behavior after the change

After refactoring, test this sequence carefully:

  1. Load the first route directly.
  2. Navigate to a second route.
  3. Navigate to a third route.
  4. Navigate back to the first route.
  5. Use both Link navigation and browser back/forward.

If the hang disappears after moving or keying the suspense boundary, the issue was almost certainly caused by persistent layout caching interacting with async route-sensitive rendering.

For the reproduction in the linked issue, the most reliable solution is:

  • Keep layout.tsx as stable as possible.
  • Move route-dependent async work into the page or a nested server component.
  • If needed, add a route-based key to the suspended subtree.

That approach aligns with how the Next App Router expects layouts to behave.

Common Edge Cases

Using cookies(), headers(), or auth state in the layout

These APIs mark rendering as dynamic. If the layout also owns suspense-based async work, you are much more likely to hit cache reuse issues. Keep request-bound logic out of the persistent layout whenever possible.

Mixing static and dynamic fetch policies

A layout that combines cache: ‘force-cache’, cache: ‘no-store’, or revalidation settings inconsistently can produce confusing behavior. If one subtree is cached and another is always dynamic, navigation bugs become harder to diagnose.

Assuming Suspense always resets on route change

That assumption is safe for pages and keyed subtrees, but not for a reused layout instance. If you need a reset, make it explicit with a key or move the boundary.

Client components hidden inside the server layout tree

A client component that reads router state, local state, or effects can make the issue appear nondeterministic. The server tree may be reused while the client subtree waits on a different transition lifecycle.

Loading UI masking the real problem

A fallback spinner can make the app look like it is just slow, when it is actually stuck. Watch the network tab and React/Next server logs to confirm whether a response is still pending or the same cached tree is being reused.

FAQ

Why does it fail after the second navigation instead of the first?

The first transition often establishes the cached layout state successfully. On the next transition, Next reuses that persistent layout while React expects a fresh resolution for the route-sensitive suspended content. The mismatch becomes visible only once cache reuse starts affecting later navigations.

Is this a React Suspense bug or a Next.js App Router bug?

It is primarily an integration issue between React Suspense semantics and Next.js layout persistence/caching behavior. Suspense itself is working as designed, but the surrounding route segment reuse can leave the boundary in a stale state.

Can I fix it by forcing dynamic rendering everywhere?

Usually no. Making everything dynamic can reduce caching, but it does not change the fact that layouts persist across navigations. The better fix is architectural: move route-sensitive async work out of the layout or key the subtree so it remounts.

If you need a short rule to remember: persistent layouts should stay stable, and route-sensitive suspense should live in pages or explicitly keyed nested components.

Leave a Reply

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