When I closed out my July post on the Next.js 15 migration, I argued that keeping dependencies current is a standing obligation rather than a project with an end date. It's an easy thing to write and a harder thing to live by, and ten weeks later the site gave me the chance to find out how seriously I'd meant it.
It began with a Friday email from Vercel:
Production deployment failed. Node.js Version "20.x" is discontinued and must be upgraded. Please set Node.js Version to 24.x in your Project Settings to use Node.js 24.
Nothing in the repository had changed. The deploy that failed was the scheduled job that refreshes the CVE dataset, and it failed because the platform had retired the runtime my project was still pinned to. There is something clarifying about a failure you didn't cause: the code was fine, and it still couldn't ship.
I could have changed the setting in the dashboard and moved on. I pinned it in package.json instead, since Vercel honors engines.node over the project setting, and a runtime version that lives in the repository gets reviewed and versioned like everything else:
"engines": { "node": "24.x" }That small change exposed a larger inconsistency. CI was still testing on Node 20, the refresh job ran on 22, and my own machine was on 26, which meant the version I tested against, the version I developed on, and the version I shipped had quietly drifted apart. All three are on 24 now.
The advisory I wasn't looking for
With the project open anyway, I ran npm audit, mostly out of habit. The result was less routine than I'd hoped.
Next.js 15.5.22, the version this site had been running since July, now carried a critical advisory: unauthenticated remote code execution through the Image Optimization API, triggered with AVIF files (GHSA-2xp9-vwfh-vxw4). A second critical advisory affected only Windows-hosted servers, and sharp, the image library Next depends on, had a high-severity issue of its own. In July that same version had passed a clean audit. The clean bill of health lasted roughly two months.
The remedy was a patch release, 15.5.27, along with a bump to the sharp override. Everything passed on the first attempt, and I shipped it immediately and by itself. I already knew the larger upgrades were coming, and I had no interest in letting a remote code execution fix wait in a branch behind a CSS framework migration.
Paying for data no one reads
For months the build had emitted the same four warnings about lab pages exceeding 128 KB of page data, and I had long since stopped reading them. I'd filed them under the cost of rendering content statically, which turned out to be accurate for half of them.
The threat hunt page offers a list of ATLAS techniques to choose from. The list displays only an ID and a name, yet every entry was carrying its full description into the page payload, because the loader returned entire records. The API route that generates hypotheses already looks the description up on the server by ID, so the browser was receiving 128 KB of text it never rendered. Removing it took the page from 147 KB to 14 KB.
The CSF crosswalk page had the opposite kind of redundancy: it shipped the same mapping in both directions. Before relying on that, I verified that the inverse index was an exact inversion of the forward one, all 2,052 pairs in the same order, and then had the page send one direction and reconstruct the other in the browser. That brought it from 270 KB to 174 KB. Because the page now depends on that equivalence silently, I added a test that fails if the generated data ever stops honoring it.
The other two warnings, on the Sigma rule library and the ATT&CK parent mapper, remain, and deliberately so. Those pages display everything they send. Getting them under the threshold would mean deferring content until after render, which reintroduces the spinner-where-the-content-should-be pattern I spent some effort removing from this site earlier in the year.
Next 16 and React 19
React 19 type-checked without a single change, which was not the outcome I had budgeted time for. Next 16 was less accommodating and refused to build at all:
Error: loader .../@next/mdx/mdx-js-loader.js for match "{*,next-mdx-rule}"
does not have serializable options.Next 16 builds with Turbopack by default, and Turbopack passes loader options across a process boundary, so they must be serializable. My MDX configuration handed the plugins over as imported functions, which also explained why next.config.js had grown into an async factory whose only purpose was to await ESM-only packages from CommonJS. As it happens, @next/mdx accepts plugins by package name and resolves them itself, so one change addressed both problems:
rehypePlugins: [
"rehype-slug",
["rehype-autolink-headings", { behavior: "wrap" }],
["rehype-pretty-code", { theme: { night: "github-dark", paper: "github-light" } }],
],I was glad to see the factory go. It had always been a workaround masquerading as configuration.
The remaining changes were smaller but not trivial. next build no longer runs ESLint, and on this site the build was the only lint gate that mattered, because Vercel deploys main without waiting for CI; the build script now runs lint explicitly before building. eslint-config-next moved to flat configs, which let me delete the compatibility shim I had been using to load it. The React Hooks plugin now includes rules written with the React Compiler in mind, and it flagged thirteen effects that set state, nearly all of them restoring saved preferences or reading the URL after hydration. That pattern is intentional in a statically generated site, so I disabled the rule and documented the reasoning beside it rather than contorting the components to satisfy it.
The most satisfying find was a latent bug in my own test configuration. Jest mapped .mdx imports to an empty stub using the key '\.mdx$', which in a JavaScript string becomes the regular expression .mdx$, where the dot matches any character, including the slash in @next/mdx. Nothing had imported that package by name before this upgrade. Once next.config.js did, the test suite began receiving an empty module and failing with createMDX is not a function. The fix was a single backslash; the bug had been there since the day I wrote the mapping.
A correction to the July post
In July I recommended the following override to clear a set of brace-expansion advisories that ran through roughly thirty of my development dependencies:
"brace-expansion": "^5.0.8"That advice did not survive this upgrade. After the Next 16 reinstall, ESLint began crashing with expand is not a function, and the override was the cause: it forced version 5 onto every copy of the package, including the one loaded by an older minimatch that expects the version 1 interface.
It was also unnecessary. Each of those advisories had been patched in every version line my tooling actually uses, so simply refreshing the lockfile would have resolved them without forcing anything across a major-version boundary. A blanket override like that can pin code to a version it was never written against, and the failure tends to surface much later, during an unrelated reinstall, when the connection is least obvious. I should have checked the patched ranges before reaching for the override, and I'd rather say so here than leave the original recommendation standing.
Tailwind 4, and what only the screenshots caught
Tailwind's upgrade tool handled most of the mechanical work competently. It moved the theme out of tailwind.config.js and into CSS, swapped the PostCSS plugin, and renamed utilities whose scales shifted in v4 so that the rendered result would stay the same. It also deleted the config file outright, and with it every comment I had written explaining why a given token has the value it does. I restored those by hand, since the reasoning matters more than the values.
At that point the build, lint, the type checker and all 1,615 tests passed. None of that told me whether the site looked right, which, for a CSS framework upgrade, is the only question that really matters.
So I built the Tailwind 3 version in a separate worktree and captured full-page screenshots of ten pages in both themes at desktop and phone widths, forty images per build. Before comparing the two, I compared the Tailwind 3 build against itself to confirm the captures were deterministic. They were pixel-identical, which meant any difference against Tailwind 4 would be genuine. The first real comparison found three.
The serious one involved the instrument panels. The data tools on this site stay dark even in the light theme, and each panel achieves that by redeclaring the dark palette's custom properties for its own subtree. Under Tailwind 3, a utility such as text-ink-muted compiled to rgb(var(--ink-muted)) on the element itself, so it resolved against the panel's values. Tailwind 4 instead defines --color-ink-muted once on the root, and the var() inside it resolves there, before any panel has a say. In the light theme, every token-based color inside a dark panel therefore took its light-theme value, which left the buttons grey on a dark background. No test would have caught this, because nothing was broken in the conventional sense; it was simply wrong. Declaring those colors with @theme inline restored per-element resolution and fixed it.
The second was a cascade subtlety I hadn't known I depended on. The article title uses text-4xl lg:text-5xl leading-tight. In Tailwind 3, the later lg:text-5xl rule overrode leading-tight with its own line height on desktop; in Tailwind 4, leading-tight wins at every breakpoint. The title, along with two paragraphs on the home page, now states explicitly the line height it had been rendering with all along.
The third was a single pixel. Tailwind 4's reset removes the browser's default 1px padding on table cells, so each article table is now a pixel shorter. I decided that one could stand.
After the fixes, 36 of the 40 screenshots matched exactly, and the remaining four were the same article, two pixels shorter because of those tables. As a final check, I loaded sixteen pages in a browser on both builds and compared the console output, which was identical.
What stays on the old version, for now
TypeScript 7 and ESLint 10 will have to wait. The lint plugins that eslint-config-next relies on don't support either yet, and I'd rather run current tooling that works than newer tooling that doesn't. One advisory also remains in npm audit, in braces, and it has no patched release at all; it enters only through Next's own lint plugin and never reaches the deployed site.
In retrospect
Most of what I took from this is unglamorous. Pin the runtime in the repository rather than in a dashboard. Treat a clean audit as a snapshot, not a status. Ship critical fixes on their own, ahead of anything more ambitious. Revisit the warnings you've learned to ignore, because a few of mine were pointing at real waste. Check whether a vulnerability is already patched within each version line before forcing a major version across the tree. And when the upgrade is fundamentally visual, verify it visually: 1,615 passing tests had nothing to say about the instrument panels, while forty screenshots surfaced the problem on the first run.