# Dynamic Routes Aren't Broken. Next 16 Cache Thinking Is.

> Cache Components plus dynamic routes throws 'Uncached data was accessed' — the generateStaticParams pattern plus the escape hatch. Relief inside.

Source: https://boringpush.com/blog/nextjs-16-ppr-removed-cachecomponents

## "impossible" dynamic routes after upgrading

February 2026, r/nextjs: ["It's impossible to use dynamic routes in Next.js 16 with Cache Components enabled"](https://www.reddit.com/r/nextjs/comments/1queygw/its_impossible_to_use_dynamic_routes_in_nextjs_16) — fully cached app, dynamic segments needed, framework says no. The thread's answer is nuanced (generateStaticParams changes the equation), but the pain is real: upgrade day, routes dead, docs ambiguous.

November 2025: ["Uncached data was accessed outside of…"](https://www.reddit.com/r/nextjs/comments/1ovmx53/is_this_a_known_limitationbug_with_cache) — pages mostly static, one dynamic read, Next treats the whole route as suspect. Confusion with `use cache` vs `use cache: remote` piles on.

May 2026: [cacheComponents state "ghosting"](https://www.reddit.com/r/nextjs/comments/1t15woi/nextjs_16_cachecomponents_bug_component_state) — component state haunting across navigations, fixed by restructuring layouts outside `[locale]`.

Three threads, one wound: the caching model moved underfoot mid-upgrade. This post is the painkiller — the pattern that makes dynamic routes work again.

## PPR thinking vs Cache Components thinking

| Assumption | PPR era (≤15) | Cache Components (16) |
|---|---|---|
| Dynamic + cached mix | Partial prerender handles | Explicit boundaries required |
| Uncached read in static page | Works (dynamic hole) | Error: "Uncached data accessed" |
| Params handling | Sync, anywhere | Async + generateStaticParams shape |
| Layout placement | Flexible | Wrong nesting ghosts state |
| Mental model | Prerender with holes | Cache by default, opt out loudly |

Three details make it bite mid-migration:

1. **Errors accuse your code of crimes.** "Uncached data was accessed" reads like you broke a rule nobody stated — the rule (cache-by-default with explicit dynamic boundaries) inverts fifteen years of dynamic-by-default intuition. Developers debug their code for hours before learning the framework moved.
2. **Ghosting punishes reasonable nesting.** Layouts inside `[locale]` (or similar groupings) interact with cache boundaries to resurrect stale component state — the May thread's fix (restructure outside the group) is architectural, not obvious. Cache bugs wear state-bug costumes.
3. **Partial knowledge is worse than none.** Teams migrating "just the easy routes" create hybrid apps where cached and dynamic semantics collide at boundaries nobody mapped. Half-migrated caching is the most dangerous state — confident enough to ship, broken enough to ghost.

The corridor post ordered the upgrade. This one defuses its quietest bomb.

## Nextjs 16 ppr cachecomponents pattern

Known paths static, unknown paths explicit, layouts flat — the shape that satisfies the new model.

**Step 1 — Declare known dynamic paths with generateStaticParams.**

```js
export async function generateStaticParams() {
  const slugs = await db.posts.listSlugs(); // known at build: pre-rendered, cached
  return slugs.map((slug) => ({ slug }));
}
export default async function Post({ params }) {
  const { slug } = await params;            // async params (corridor rule holds)
  // ... cached read path, no "uncached" error: path was declared
}
```

Declared paths get full cache treatment — fastest, cheapest, error-free.

**Step 2 — Give unknown paths an explicit dynamic boundary.**

```js
// unknown slug at request time: fetch with explicit cache opt-out + loading.tsx
import { unstable_noStore as noStore } from "next/cache"; // verify current API
export default async function Post({ params }) {
  const { slug } = await params;
  noStore(); // this boundary is dynamic ON PURPOSE — declared, loud, correct
  const post = await db.posts.findBySlug(slug);
}
```

`loading.tsx` beside the route keeps UX smooth while the boundary resolves. The principle: every dynamic read sits inside a declared boundary — nothing "accidentally" dynamic survives review. Reviewers grep for unmarked dynamic reads the way they grep for missing guards: absence of declaration is the bug.

**Step 3 — Flatten layouts that ghost.**

```js
// BEFORE (ghosts): app/[locale]/layout.js holding stateful providers
// AFTER: move stateful providers OUTSIDE the dynamic segment group
app/layout.js            // providers here: stable across navigations
app/[locale]/layout.js   // locale strings only: thin, stateless
```

State above dynamism, strings below — providers live where cache boundaries can't resurrect them. The May thread's fix generalized: audit every layout for statefulness × segment-nesting, hoist state up, keep segment layouts thin. Ghosting dies structurally when state can't straddle boundaries.

That's the deal — cache model mastered, not feared.

## The cache-migration scorecard

| Check | Before pattern | After pattern |
|---|---|---|
| Declared dynamic routes | "Uncached data" errors | Cached, error-free |
| Unknown slugs | Crashes | Explicit boundary + loading UI |
| State ghosting | Haunted navigations | Flat layouts, stable state |
| Review rule | None (hope) | Grep: unmarked dynamic reads fail |
| Upgrade confidence | Threads like February's | Checklist green |

The tax, named out loud: explicit boundaries add ceremony to every dynamic read (declarations, opt-outs, layout audits) — the "just fetch" era is over, mourned by no one who paid its debugging bills. BoringPush prices ceremony against ghost-hunts: declared dynamism reviews in minutes, haunted state debugs for days. Loud boundaries beat silent hauntings, every migration, forever.

## FAQ

### Is nextjs 16 ppr cachecomponents migration mandatory or optional?

Effectively mandatory on the upgrade path: PPR APIs are removed/renamed (verify current codemod), and Cache Components is the model 16 optimizes for. Delaying means pinning old majors while security patches move on — the corridor post's scheduling advice holds. Migrate on a quiet week with this pattern; the alternative is forced migration during an incident, which is how ghost stories start.

### How do I know which routes are "declared" vs "boundary" cases?

Inventory: list every dynamic segment, mark build-known (generateStaticParams) vs request-known (boundary + loading). No route stays unmarked — the inventory itself is the review artifact, checked per PR touching routes. Unmarked routes are the entire bug class; the list converts unknown-unknowns into assigned work. Map first, migrate second, never the reverse.

### What about `use cache: remote` vs plain `use cache`?

Remote caching (verify current semantics) suits shared/dynamic-adjacent data; plain suits build-local static. The November thread's confusion is common — treat them as different tools (local memo vs distributed cache), chosen per data shape, never mixed by accident. Document the choice per call site; future-you debugging staleness will pay for that comment.

### Do Server Actions need cache-boundary thinking?

Actions mutate (uncached by nature), but their revalidation targets (`revalidateTag`/`revalidatePath`) interact with Cache Components boundaries — stale tags after migration are the classic "it worked on 15" complaint. Audit every revalidation call against the new boundary map in the same migration PR. Mutations plus caches are one system; migrate them together or debug them together.

### Should middleware/proxy.ts factor into cache planning?

Yes — proxy runs before cache decisions (auth, rewrites, geo), and misconfigured matchers bypass cache boundaries entirely (dynamic-by-accident at the edge). The middleware-to-proxy migration post covers the move; coordinate it with cache work so edge behavior and route boundaries agree. Edge and origin must share one caching story, told twice consistently.

### When is opting OUT of Cache Components legitimate?

Static-marketing sites with zero dynamic needs (cache everything, boundaries irrelevant) and chaotic legacy routes mid-migration (stabilize first, adopt second). Opt-out is a migration stage, not a destination — calendar the adoption (next quiet quarter) when using it. Temporary simplicity Beats permanent confusion, but only when temporary has an expiry date.

## Keep reading

- [Next 16 Async Params Broke My Build: Fix](/blog/nextjs-16-async-params-breaking) — step zero of the same migration
- [14 to 16 Upgrade: Caching Broke Everything](/blog/nextjs-14-to-16-upgrade-cache-pain) — the corridor this pattern slots into
- [Router Cache Confusing You? Next.js Caching, Fixed](/blog/struggling-with-router-cache-fix) — the mental model underneath it all
