Last updated on
What flipping the Next.js 16.3 flags actually did to this site
Next.js 16.3 shipped on August 3rd, the biggest release since 16.0 last November. Most of the writing about it will be about the performance numbers. They're worth having, so I'll cover them fast and spend the rest on the part that actually made me do work.
The free half, in one paragraph
Upgrade and change nothing, and you get: up to 90% less dev-server memory, from Turbopack's disk caching and memory eviction now on by default; faster next build, because the same FileSystem Cache reuses unchanged artifacts across builds; optional type checking through the native TypeScript 7; and about 22% more requests under load, from the App Router moving off web streams to native Node.js streams. There's also a pile of smaller DX niceties (a catchError boundary that stops swallowing notFound() and redirect(), root params so you stop prop-drilling [lang], import.meta.glob in Turbopack) and two experimental flags worth watching: a Rust port of the React Compiler, and useOffline. The release post has the numbers and code. None of it needs a decision from you, which is why it's the boring half. You type npm install next@latest and you're done.
The half that's a bet
The interesting half is opt-in, and it's Next.js walking something back. Server Components made pages cheap to render but navigations feel slow, and the fix the framework reached for was implicit caching. That caching became the thing people got burned by most: you'd cache something you didn't mean to, or miss caching something you thought you had, and the rules were hard to keep in your head.
The 'use cache' directive is the replacement, and 16.3 is where it grows up. It's explicit, and as of this release it also does client-side caching, which is what makes navigations feel instant without the old implicit model underneath. You opt in with two flags:
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
}On top sits a suite called Instant Navigations: finer control over how much of a page a link prefetches, a DevTools panel that flags any navigation that isn't instant, better ISR that serves a shell to the first visitor and upgrades it in the background, and a Playwright helper that fails a test when a route quietly stops being instant. The line to notice in the announcement: these behaviors become the default in a future major version. So the migration isn't really optional. It's a question of whether you pay for it now, on your schedule, or later, when an upgrade makes you.
I wanted to know the size of that bill before it stops being my choice. So I turned the flags on.
I turned the flags on for this site
On a throwaway branch I never meant to merge, dependency bumped to 16.3 in an isolated install, building against my local database, never production. This site is a Payload CMS front end on Next, reading from Neon Postgres, with every content query going through one client module. If Cache Components hurts anywhere, it's on an app whose every page reads from a database to render. I set both flags, ran a build, and let the errors tell me what the migration was.
It rejected my route segment configs first. The one API route that backs up the database exports dynamic = 'force-dynamic' and runtime = 'nodejs', and both errored as incompatible with cacheComponents. The runtime rejection is worth flagging, because the migration guide only warns you about runtime = 'edge'; in practice the export gets rejected whatever its value. Deleting both was right anyway, since every route is dynamic by default now.
Then it refused to build a route whose list was empty. A generateStaticParams that returns an empty array is now a hard error, not a deferral. My local database had no tags or categories, so the two taxonomy archive routes couldn't build at all. On a content site that's a genuine landmine: any archive whose term list can legitimately be empty now fails the build instead of rendering an empty page. I seeded one of each to get past it, but I wrote it down, because "builds fine until the day the list is empty" is exactly the bug that passes locally and breaks in CI.
Then came the one that actually is the migration. Every uncached database read threw the same error during prerender: Next hit uncached data, and I needed to either wrap it in a Suspense boundary, cache it with 'use cache', or mark the route as allowed to block. That single error is the whole cost of Cache Components for a database-backed app. Nothing is cached unless you say so, and a query you run to render a page is data you haven't accounted for.
Here the app's own history decided how bad the day would be. Because every content query goes through one client.ts module, "cache the data layer" meant adding two lines, 'use cache' and cacheLife('max'), to each of a dozen near-identical functions. Scattered inline across twenty components, this is a bad afternoon instead of a mechanical one. When I wrote up this blog's content model I described that data-access file, mostly for how its helpers query Payload's Local API the same way; this is the migration where having every read in one place quietly paid off.
And the thing I'd braced for didn't happen. I expected Payload's Local API to fight 'use cache', to choke on something non-serializable or reach for request context a cached scope doesn't have. It didn't. The reads went into the cache boundary and came back out clean. That's the most reassuring result of the whole exercise, because "your CMS and the new cache primitive don't compose" would have been a real reason not to adopt this, and it wasn't true here.
The last two errors had nothing to do with the database, and they're the ones I'd warn you about. With the data layer cached, the build stopped on new Date().getFullYear(), the copyright year in my footer, because reading the current time during prerender is now an "unstable value" error. I cached the footer, and the year freezes at build, which under the old fully-static model it already did. Then it stopped again on Date.now(), and this time it wasn't my code. It was inside Shiki, the syntax highlighter, deep in its codeToHtml. A dependency I don't control tripped the same guard. I fixed it the same way, caching the highlight output, which is deterministic per snippet. But the lesson stuck: the prerender guard doesn't care whose code called Date.now(), and you can't grep node_modules for the calls about to bite you.
After that the build went green. The whole migration was five files and about thirty lines, twenty-four of them the same two-line directive pasted into the data layer. What came out was a split across three rendering modes the old model couldn't have described:
The build also quietly renamed my middleware to "Proxy" and ran on Turbopack rather than the webpack config still sitting in next.config, a reminder that a webpack() customization isn't consulted by a Turbopack build. And the revalidatePath() hook I wrote a while back, the one that fires on save so the blog index refreshes without a redeploy, still works untouched. The idiomatic version now would tag the cached reads and expire them by tag, but the old approach didn't break, which is more than I expected from a caching rewrite.
So is it simpler?
"It was thirty lines" is true and not the whole answer. It was small on this app because the data access already went through one module and the content changes on a redeploy, not per request. On an app with per-user data, cookies read in a shared layout, and queries scattered across components, the same flags surface a lot more work, most of it the harder kind: deciding what streams, what caches, and where the boundaries go.
But the model underneath does feel simpler to reason about, and I didn't expect to say that. Implicit caching was hard because you couldn't see it; you inferred what was cached and hoped. Now the answer to "is this cached?" is "only where it says 'use cache'," and the build won't let you ship a route whose caching you haven't decided on. The complexity didn't vanish. It moved from something you infer to something you declare, and got louder on the way. Louder is better. A build error naming the exact line that read the current time beats a page that silently serves stale content in production, which is the failure the old model was good at.
Two honest limits. I read all of this through next build, which runs the same validation as the interactive Instant Insights overlay, just where it would really catch me, in CI; I didn't sit in the overlay clicking through insights. And cacheLife('max') here is in-memory, so the cached values are thrown away on every redeploy and instance teardown, which is fine for a site whose content only changes when I publish but would push a busier app toward a durable cache handler.
I'm not merging the branch. The point was to find out what the migration costs before the day it stops being optional, and now I know: on this site, not much, and the part I was most afraid of turned out to be the part that just worked.
Comments
No comments yet. Be the first to comment.