How to Fix: Loading.js fallback of dynamically rendered pages is not prefetched in the majority of cases

7 min read

Next.js App Router prefetching can silently skip the loading.js fallback for dynamically rendered pages, which means users click a link expecting an instant transition but still see a delayed navigation while the server waits for fresh work. This issue appears most often when a route is treated as dynamic, because Next.js does not consistently prefetch the route’s loading boundary the same way it does for static segments.

Issue Overview

In the reproduced case, a page using the App Router has a loading.js file and is linked with normal client navigation. In production, many of those links do not prefetch the loading fallback when the target page is dynamically rendered. As a result:

  • The user clicks a visible link.
  • Next.js starts navigation only at click time.
  • The expected instant loading state is missing or arrives late.
  • The route feels slower than a comparable static route.

This is not usually caused by a broken loading.js file. The more common cause is that prefetch behavior differs between static and dynamic routes in the App Router pipeline.

Understanding the Root Cause

For static routes, Next.js can often precompute and prefetch more aggressively. That includes route data, React Server Component payloads, and the assets needed to render a fast transition. For dynamically rendered pages, however, Next.js must assume that the response depends on request-time state such as headers, cookies, uncached fetches, or explicit dynamic configuration.

When a route is dynamic, Next.js typically avoids full speculative work because the prefetched result could be stale, user-specific, or impossible to resolve safely ahead of time. In the majority of cases described by this issue, the framework therefore does not prefetch the loading boundary in a way developers expect. The route still has a loading.js file, but that fallback is not reliably warmed up before navigation.

Technically, this usually happens when one or more of the following make the segment dynamic:

  • Using cookies(), headers(), or other request-bound server APIs.
  • Using fetch(..., { cache: 'no-store' }) or equivalent uncached data access.
  • Setting export const dynamic = 'force-dynamic'.
  • Reading request-specific search params in ways that force request-time rendering.

Once the route becomes dynamic, the prefetch heuristic changes. That is why the same navigation may feel instant for a static page but delayed for a dynamic one, even though both define loading.js.

Step-by-Step Solution

The practical fix is to reduce unnecessary dynamic rendering and restructure the route so Next.js can prefetch more effectively. If the page truly must stay dynamic, the fallback behavior should be treated as request-time rather than guaranteed preloaded UI.

1. Confirm the route is actually dynamic

Inspect the target page and layout for request-bound APIs and uncached fetches.

// app/products/[slug]/page.tsx
import { cookies, headers } from 'next/headers'

export default async function Page() {
  const cookieStore = cookies()
  const headerStore = headers()

  const res = await fetch('https://example.com/api/products', {
    cache: 'no-store',
  })

  const data = await res.json()

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

Any of the patterns above can force dynamic rendering.

2. Move stable data to cacheable fetches when possible

If the page content does not need per-request freshness, switch to cacheable data access so the route can behave more like a static or revalidated page.

// app/products/[slug]/page.tsx
export default async function Page({ params }: { params: { slug: string } }) {
  const res = await fetch(`https://example.com/api/products/${params.slug}`, {
    next: { revalidate: 60 },
  })

  const data = await res.json()

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

Using next: { revalidate: 60 } often allows Next.js to treat the route as cacheable enough for better prefetch behavior.

3. Remove unnecessary request-bound APIs from the page segment

If only a small part of the page needs cookies or headers, isolate that logic in a smaller dynamic boundary instead of making the entire route dynamic.

// app/products/[slug]/page.tsx
import ProductDetails from './ProductDetails'
import Personalization from './Personalization'

export default async function Page({ params }: { params: { slug: string } }) {
  const res = await fetch(`https://example.com/api/products/${params.slug}`, {
    next: { revalidate: 60 },
  })

  const product = await res.json()

  return (
    <>
      <ProductDetails product={product} />
      <Personalization />
    </>
  )
}
// app/products/[slug]/Personalization.tsx
import { cookies } from 'next/headers'

export default function Personalization() {
  const theme = cookies().get('theme')?.value ?? 'light'
  return <div>Theme: {theme}</div>
}

The exact architecture may vary, but the goal is to keep the main route as cache-friendly as possible.

4. Keep the loading boundary close to the slow segment

Make sure the route actually defines the fallback where the delay occurs.

// app/products/[slug]/loading.tsx
export default function Loading() {
  return <div>Loading product...</div>
}

This does not force prefetch for a dynamic route, but it ensures the correct fallback exists once navigation starts.

5. Use static generation for known params when possible

If dynamic segments are known ahead of time, generate them statically.

// app/products/[slug]/page.tsx
export async function generateStaticParams() {
  const res = await fetch('https://example.com/api/products', {
    next: { revalidate: 300 },
  })

  const products = await res.json()

  return products.map((product: { slug: string }) => ({
    slug: product.slug,
  }))
}

This is one of the most effective ways to restore the fast path for prefetching.

6. Avoid forcing dynamic rendering unless it is truly required

// Avoid this unless necessary
export const dynamic = 'force-dynamic'

If that flag exists only as a workaround from earlier debugging, removing it may immediately improve route prefetch behavior.

7. Verify in production mode only

Prefetch behavior should be tested with a production build, not local development heuristics.

pnpm build
pnpm start

Then open the app, navigate through linked dynamic routes, and inspect the network activity in the browser. You should compare:

  • A route with request-time rendering.
  • A route converted to revalidated or static rendering.

If the converted route now transitions faster and prefetches more consistently, the root cause was the route’s dynamic status.

8. If the route must remain dynamic, design for click-time loading

Some pages genuinely depend on request-specific state. In those cases, the correct solution is not to fight the framework but to optimize the server work and make the loading.js UI lightweight. For example:

  • Reduce slow backend calls.
  • Parallelize data fetching.
  • Stream independent sections.
  • Cache backend responses where business rules allow.
// Example: parallel fetches in a dynamic page
export default async function Page() {
  const [productRes, reviewsRes] = await Promise.all([
    fetch('https://example.com/api/product/1', { cache: 'no-store' }),
    fetch('https://example.com/api/reviews/1', { cache: 'no-store' }),
  ])

  const [product, reviews] = await Promise.all([
    productRes.json(),
    reviewsRes.json(),
  ])

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{reviews.length} reviews</p>
    </div>
  )
}

Common Edge Cases

  • Works in development but not production: App Router behavior differs significantly between dev and production builds. Always validate this issue with build and start.
  • Parent layout is dynamic: Even if the page looks cacheable, a parent layout.tsx using cookies() or headers() can make the whole subtree dynamic.
  • Mixed cached and uncached fetches: A single no-store fetch may push the route onto the dynamic path.
  • Link visibility assumptions: Next.js prefetching depends on link visibility, browser conditions, and internal heuristics. A link not entering the viewport may not prefetch at all.
  • Search params causing dynamic rendering: If your route depends on request-time query values, prefetching may still be limited even with a valid loading.js.
  • Overusing force-dynamic: Teams sometimes add it globally during debugging and forget to remove it, which disables many optimizations.

FAQ

Why does loading.js exist if it is not always prefetched?

loading.js defines the fallback UI for a route segment, but having a fallback is not the same as prefetching it. Prefetch depends on whether Next.js can safely and efficiently prepare that route ahead of navigation.

Can I force Next.js to prefetch dynamic routes exactly like static ones?

Not reliably in all cases. If a route depends on request-time state, Next.js intentionally limits aggressive prefetching. The best fix is to make the route more cacheable or isolate the dynamic parts.

What is the safest long-term fix for this bug?

The safest approach is to refactor the target route toward static generation or ISR, remove unnecessary request-bound APIs, and keep truly dynamic logic in narrower boundaries. That aligns your code with how the App Router optimizes navigation.

If you are debugging this exact issue from the reproduction repository, the key takeaway is simple: the missing prefetch is usually a rendering-mode problem, not a broken loading component. Once the route stops being unnecessarily dynamic, Next.js can resume its faster navigation path in far more cases.

Leave a Reply

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