The Problem
I was helping a SaaS dashboard team migrate from Next.js 15 to 16 this week. next dev ran clean. The first CI build with experimental.dynamicIO: true and experimental.ppr: 'incremental' enabled blew up on three different routes with:
Error: Route "/dashboard/[teamId]" used an uncached IO call
(fetch to https://api.example.com/teams/1/usage).
With `dynamicIO`, any IO must be cached, behind a Suspense boundary,
or marked dynamic with connection().
They had flipped on dynamicIO because the upgrade docs recommended it for partial prerendering. The build worked locally with the defaults. The production build, with the two experimental flags on, refused to ship. If your Next.js 16 build is failing on uncached IO call errors after enabling dynamicIO, this is the same wall I just walked the team through.
Why It Happens
dynamicIO in Next.js 16 is strict by design. The compiler walks every Server Component, every route segment, every Server Action, and rejects any data access that is not one of three explicit shapes:
- Wrapped in a
"use cache"function (with optionalcacheLife/cacheTag) - Awaited inside a
<Suspense>boundary so it can render in a streamed shell - Gated through
connection()fromnext/serverso Next knows the route is fully dynamic
A bare await fetch() at the top of a Server Component is none of those. Under Next.js 15 the compiler let it through and quietly treated the segment as dynamic. Under 16 with dynamicIO, that ambiguity is a hard build error. The build refuses to ship a route that could be statically prerendered with data the compiler cannot verify came from a deterministic source.
The friction is that the error message gives you the URL but not the file. On a large project you may have a dozen routes hitting the same API, and only some of them are wrapped in cache. The Vercel deploy logs do not always include the source path either. I lost ten minutes grepping the wrong components before I gave up and built locally with tracing on.
The Fix
Step 1: Find the file. Build with prerender tracing enabled and the output includes the segment that triggered the call:
NEXT_PRIVATE_DEBUG_CACHE=1 next build --debug-prerender 2>&1 \
| grep -B 3 "uncached IO"
The trace now includes the source path. In my project it was app/dashboard/[teamId]/usage-card.tsx, a Server Component three levels deep, not the page file itself. That is almost always where it is.
Step 2: Choose your shape per call site. Decide whether the data should be cached, streamed, or fully dynamic. The decision is per fetch, not per route. Mixing all three shapes inside one page is normal and expected.
If the data is shared across users and changes rarely, cache it:
// app/dashboard/[teamId]/usage.ts
"use cache";
import { cacheLife, cacheTag } from 'next/cache';
export async function getTeamUsage(teamId: string) {
cacheLife('minutes');
cacheTag(`team-usage:${teamId}`);
const res = await fetch(
`https://api.example.com/teams/${teamId}/usage`
);
if (!res.ok) throw new Error('usage fetch failed');
return res.json();
}
The call is now deterministic from the compiler's view and the build passes for that segment.
If the data is per-user and must stream after the shell, wrap it in Suspense:
// app/dashboard/[teamId]/page.tsx
import { Suspense } from 'react';
import { UsageCard } from './usage-card';
import { UsageSkeleton } from './usage-skeleton';
export default function Page({
params,
}: {
params: Promise<{ teamId: string }>;
}) {
return (
<Suspense fallback={<UsageSkeleton />}>
<UsageCard params={params} />
</Suspense>
);
}
The fetch inside UsageCard no longer needs to be cached because Next can prerender the shell with the skeleton and stream the card on request.
If the data must be fully dynamic per request (admin pages, signed-in API tokens), gate the segment:
import { connection } from 'next/server';
export default async function Page() {
await connection();
const data = await fetch('https://api.example.com/admin', {
cache: 'no-store',
}).then((r) => r.json());
return <AdminDash data={data} />;
}
connection() tells Next the route opts out of prerender entirely. The build no longer tries to bake it and the uncached fetch is now legal.
Step 3: Watch for incidental fetches. The trap I keep hitting is a small helper like getUserFlag() called from a dozen layouts. Wrap the helper once with "use cache" and every caller is now valid. Do not annotate every call site, that is how you ship inconsistent cache lives.
Step 4: Confirm with a clean next build. Nuke the cache and rebuild:
rm -rf .next && next build
You should see the route now prerendered as Static (cached path) or marked PPR (Suspense path) or Dynamic (connection path) in the build output. The Next.js dynamicIO docs cover the three shapes in detail if you want to keep a reference open while you migrate.
Lesson Learned
dynamicIO is not a flag you flip casually. It is a contract: every IO call has a shape, and the compiler enforces it. The payoff is real, partial prerendering finally works the way the announcements have been promising for two release cycles, but the migration cost is non-zero on any non-trivial app.
If you are mid-migration from Next.js 15 to 16 and your CI is throwing uncached IO errors faster than your team can fix them, I do this kind of upgrade work routinely — see my services. For the related caching gotcha that hits the moment you turn "use cache" on, see Next.js use cache stale data fix.