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.
| Cause | Where you see it | Fix |
|---|---|---|
| cookies, headers, searchParams | Build error, or dynamic symbol in the old model | Wrap the read in Suspense, or accept dynamic |
| Uncached data | Build error, or dynamic symbol in the old model | Cache with use cache, or stream behind Suspense |
| Dynamic segment not prerendered | Fallback row for the segment | Add generateStaticParams |
| dynamic = 'force-dynamic' | Dynamic symbol in build output | Remove 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.
Route (app)
┌ ○ /about
├ ƒ /dashboard
└ ○ /blog
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demandWithout 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:
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 routeThe 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
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.
Frequently Asked Questions
Does the dynamic symbol in build output mean the route is slow?
Can one component make the whole route dynamic?
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.
More in this topic
`generateMetadata` Explained with Real Examples
What generateMetadata does, when it runs, and how to use it for real routes: awaited params, deduplicated data fetching, extending parent metadata, and returning a 404 from metadata.
Canonical URLs in Next.js: `metadataBase`, `alternates.canonical`, and Dynamic Pages
How canonical URLs work in the Next.js App Router: setting metadataBase once, writing alternates.canonical per route, handling dynamic segments, and what happens when the base URL is missing.
Open Graph and Twitter Card Metadata in Next.js
How to write Open Graph and Twitter card metadata in the Next.js App Router: the openGraph and twitter fields, automatic card defaults, article tags, and image merge rules.