Skip to content

Next.js 16.3 From the CTO Seat: Five Apps, One Month, Three Bugs

· 8 min read · view as markdown

Next.js 16.3 shipped in early August with the headline "biggest update since 16.0". We adopted it the same week across five production apps, then spent a month living with it, and this week we moved everything to the latest patch and went back over every claim in the release post. This is what held up, what did not, and what I would tell another CTO before they flip the flags.

Context, so the numbers mean something: we run five Next.js apps. Three sit in one pnpm monorepo: a 56-route admin product behind auth, a public candidate-facing site with forms and AI interviews, and a small employee portal. The other two are separate repos: an internal analytics dashboard and a trilingual marketing site with an MDX blog. All five deploy to Vercel. All five now run the React Compiler and Cache Components.

What paid off on day one

The build cache is real. Turbopack's filesystem cache for next build is on by default in 16.3. Measured this week on the latest patch, on a laptop: the 56-route app compiles in 30 seconds cold and 0.8 seconds when nothing changed. On 16.3.0 the same cached rebuild took 6.6 seconds, so the patch line got that eight times better without us touching anything. The smaller apps sit around 4 to 7 seconds cold and half a second cached. Wall-clock is higher (page-data collection and static generation are not cached), but the compile step used to be the part we waited on.

Memory eviction changed a decision, not a number. We never measured dev memory, but it is why our marketing site finally dropped next dev --webpack as its default. Long Turbopack sessions used to grow until the laptop complained; with eviction on by default that stopped being a reason to keep webpack around. Webpack stays as a fallback script, which brings its own lesson below.

catchError is the error boundary we actually wanted. A classic error.tsx swallows notFound() and redirect(), and its reset only clears client state. The new boundary from next/error does neither: it lets those two through, and its retry() re-fetches the Server Components below it. We use it around pages that fetch on the server (with the fallback reporting to Sentry) and, on the blog, around the MDX body so a broken post degrades to a "try again" block instead of a blank page.

TypeScript 7 in next build, with one pnpm gotcha. Four of the five apps type-check with the native tsc. The one that does not is the marketing site, because it lints with typescript-eslint, whose parser crashed on the TypeScript 7 API when we tried. The apps that lint with Biome never hit that. And under pnpm's isolated linker, the native compiler does not auto-include node_modules/@types, so each app needed an explicit "types" array in its tsconfig. Ten minutes, but ten confusing minutes.

Cache Components change HTTP semantics, not just speed

This is the part I would put in bold in every migration guide. Under Cache Components a page streams over a prerendered shell, and the shell goes out with a 200. If your page then calls notFound() while rendering the dynamic part, the status is already on the wire. The user sees your not-found UI. Google sees a 200.

We measured it on the marketing site before fixing it: an unknown blog slug, an unknown ad-landing slug and an unknown comparison-page slug all answered 200. Three whole route families returning "found" for garbage, on the one property where crawlers are the customer. The fix follows the framework's own guidance: establish existence before the first byte. Our proxy now checks blog slugs against a manifest generated at build time (so it cannot go stale in production, and it carries publish dates so scheduled posts still work), checks the other two against their static tables, and rewrites misses to a path no route matches. The router then serves the branded not-found page with a real 404.

The second semantic change is that segment configs are gone. export const revalidate = 3600, dynamic = "force-static", dynamic = "force-dynamic": none are allowed once the flag is on. That is not a rename. revalidate becomes a 'use cache' scope with cacheLife('hours'), and whatever reads the clock has to move inside that scope. Our blog gates posts by publish date with new Date(); outside a cache scope that is request-time data and a hard prerender error, inside it the clock is read once per cache entry and scheduled posts still appear within the hour. Once we did that, 204 pages prerendered at build and a second hit on a post went from 129 ms to 29 ms.

Root params is the unlock for i18n, and we have not pulled it yet

The release post's import.meta.glob example is a blog reading MDX files with gray-matter. That is literally ours, so we tried it in August. It compiled, and it flipped the blog routes from dynamic to fully static, which broke next-intl's request-time locale with 500s at runtime. The synchronous fs reads are load-bearing until the layout stops deriving the locale from params.

That same layout is why the marketing site carries export const instant = false on its whole locale tree: a layout that picks <html lang> and its messages from params cannot produce an instant static shell. Root params (lang() from next/root-params) plus setRequestLocale is the fix, and it is the next step for that app. If your app is internationalized with a [locale] segment, that migration is the price of Instant Navigations, and it is worth budgeting for it up front rather than discovering it through instant = false.

Experimental means experimental

The Rust-based React Compiler is the feature I was most excited about and the one that cost the most. Two things happened.

First, on our largest app it OOM-kills the build on Vercel's standard container, about 25 seconds into a cold compile. The same app builds fine through the Babel path (twice as slow, but it fits), and the smaller apps build fine with the Rust port. We reported it, a maintainer engaged the same day, and by late August they had cut the compiler's peak allocation by half, with a quadratic-growth path as the likely trigger for our code. That work is on canary. We re-ran the experiment this week on the latest 16.3 patch, on a preview deployment, and got the same SIGKILL. So our config keeps a per-target guard: Rust port everywhere except Vercel builds of that one app, where the compiler runs through Babel.

Second, the Rust port drops the jsx-<hash> scoping class from conditionally rendered elements inside stateful components, so scoped <style jsx> rules silently stop matching. It shipped our sidebar labels invisible. We kept a minimal repro repository (vercel/next.js#96694) and re-ran it this week against three versions: still broken on the latest 16.3 patch, fixed on the 16.4 canary. That turned "maybe fixed by now" into a version number, and it means our "no scoped styled-jsx" rule stays exactly until 16.4.

Two smaller things from the same bucket. output: 'standalone' combined with an adapter broke Vercel deploys on 16.3.0 (a missing trace file); the fix was merged to canary two days later and, as of this week, has still not shipped in any stable 16.3 patch. Check the tag, not the PR. And the config guard for our webpack fallback originally tested process.argv for --webpack, which never matches because next.config is evaluated in Next's internal server process. A reviewer caught it by actually running the fallback. Guards you never exercise are guards you do not have.

The bug that had nothing to do with Next.js

The marketing site's whole 16.3 adoption was reviewed, approved and merged on August 4. It was merged into the feature branch it had been stacked on, and that branch was never merged again. For a month the site ran a 16.3 binary with webpack in dev, no compiler and the old segment configs. Nothing broke, which is exactly why nobody noticed, and I only found it this week by grepping main for a flag that should have been there. We re-applied the intent by hand; a cherry-pick had ten conflicting files against a month of design work. Stacked PRs are fine. Stacked PRs whose base is not main need a reminder on the base.

What I would tell another CTO

  • Take the patches. 16.3.3 fixed two critical remote-code-execution advisories, one of them in image optimization with AVIF, which is the first format our marketing site serves. Nothing in the 16.3.x line required code changes from us. There is no reason to sit on 16.3.0.
  • Measure your 404s after flipping Cache Components. One curl per route family. If you see 200 where you expect 404, move existence checks into the proxy.
  • Treat revalidate as a migration. Find every segment config, find what reads the clock, move it inside a cache scope.
  • Give every experimental flag a per-target escape hatch and a way to re-test it. Ours is one commit on a pull-request preview and a revert. Six minutes, and now we know the answer for this patch.
  • Keep a repro repo for every bug you report. Re-running it against a new version costs two minutes and answers the question upstream comments cannot.
  • A fix on canary is not a fix in your build. Check the release tag.
  • If you are internationalized, plan the root-params migration before you plan Instant Navigations. It is the same project.

16.3 is a good release. The build cache and the error boundary alone were worth the upgrade, and Cache Components is the right model for the apps we build. But the release post describes a destination, and the road there runs through your proxy, your i18n layout and your build container's memory limit. Knowing that before you start is most of the work.