Why a Next.js Route Became Dynamic and How to Find the Cause

The common reasons a Next.js route renders dynamically instead of statically, and how to find the cause in build output or the dev overlay.

7 min read

Why a Next.js route became dynamic is a question with a few well-defined answers. In the pre-Cache-Components model the whole route flips dynamic as soon as one part reads request data.

With Cache Components enabled the question changes shape. It becomes a question of which boundary streams, because the rest of the shell stays static. Start by checking whether cacheComponents is on, since the symptom and the fix both differ by model.

What makes a route dynamic

The causes are predictable, and each has a matching fix.

CauseWhere you see itFix
cookies, headers, searchParamsBuild error, or dynamic symbol in the old modelWrap the read in Suspense, or accept dynamic
Uncached dataBuild error, or dynamic symbol in the old modelCache with use cache, or stream behind Suspense
Dynamic segment not prerenderedFallback row for the segmentAdd generateStaticParams
dynamic = 'force-dynamic'Dynamic symbol in build outputRemove the export

The old model also makes the whole route dynamic when a single fetch uses no-store, which is why one stray option can surprise you. In the Cache Components model the same causes only mark a boundary, because data is dynamic by default and you opt in to static output.

Finding the cause in build output

The next build route table marks each route with a symbol, and which symbol you are hunting for depends on the model.

texttext
Route (app)
┌ ○ /about
├ ƒ /dashboard
└ ○ /blog
 
○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

Without Cache Components, ƒ is the signal. The dashboard renders per request and cannot be served from a CDN as static HTML, which is usually why you care. Look inside it for the request-time API or the uncached read.

With Cache Components the reading changes, because routes are no longer all-or-nothing. A partially prerendered route shows a half-filled circle, meaning a shell was prerendered and the rest streams.

A route falls back to the dynamic symbol only when it has nothing to prerender at all, such as a request-dependent Route Handler or dynamic metadata. So under this model a half-filled circle is normal, and a page marked fully dynamic is the surprise worth chasing. For the complete key, see understanding next build output.

Finding the cause with Cache Components

With Cache Components enabled, the dev overlay names the blocking read directly. It surfaces a blocking-route insight when uncached data or a request-time API sits outside a Suspense boundary, and blocking-prerender insights for random values, timestamps, and crypto calls. The insight names the route and the read, so you do not have to guess.

A production build is even more direct, because the same situation fails the build instead of quietly marking the route dynamic:

texttext
Error: Route "/products/[id]": Next.js encountered uncached or runtime data during prerendering.
 
Ways to fix this:
  - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
  - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"`
  - [block] Set `export const instant = false` to allow a blocking route

The three suggestions are the three real options: stream it, cache it, or declare that this route is allowed to block. If the message cannot point at the exact line, rerun with next build --debug-prerender, which disables server minification and emits source maps so the stack lands on the blocking read.

The dynamic APIs behind these insights are explained in the dynamic APIs article, and forcing dynamic rendering on purpose is covered in the connection() function.

Where people forget to look

When the page file looks innocent, the cause is usually somewhere the route still owns but you were not reading.

  • The layout, not the page. A layout that awaits params or reads a cookie affects every route beneath it.
  • Metadata. An uncached fetch or a runtime read inside generateMetadata blocks prerendering exactly like one in the page.
  • A shared helper. A utility three imports deep can read headers, and nothing at the call site hints at it.
  • A synchronous source. An in-memory database driver or a filesystem read finishes during prerendering, so a value you expected to be fresh is frozen instead.

The last one is the reverse problem and worth naming, because it fails quietly. The route stays static and every visitor sees a build-time value, with no error to tell you.

To narrow it down quickly, build a single route with next build --debug-build-paths and add the boundaries back one at a time. Isolating one route turns a whole-app rebuild into a few seconds per attempt.

Rune AI

Rune AI

Key Insights

  • Request-time APIs and uncached data are the usual causes.
  • An unprerendered dynamic segment also forces dynamic rendering.
  • The build output marks dynamic routes with the dynamic symbol.
  • Cache Components show a blocking-route insight in the dev overlay.
  • force-dynamic and revalidate of zero opt in explicitly.
RunePowered by Rune AI

Frequently Asked Questions

Does the dynamic symbol in build output mean the route is slow?

Not necessarily. It means the route renders per request. It is often fine, but it does mean the route cannot be served from a CDN as static HTML.

Can one component make the whole route dynamic?

In the pre-Cache-Components model, yes. With Cache Components enabled, only the Suspense boundary around the dynamic read streams.

Conclusion

A route becomes dynamic for a small set of predictable reasons: a request-time API, uncached data, an unprerendered dynamic segment, or an explicit force-dynamic export. Build output and the dev overlay both point at the culprit.