Air Force Logo
Thundercats Logo
All writing
next-jscvesmigrationsecurity

How 14 CVEs finally forced my Next.js upgrade (and what the migration actually looked like)

2026-07-10·8 min read·Kirk Abbott

I'd been on Next.js 14 for over a year. The app worked. Deployments were smooth. Upgrading to 15 kept landing on the backlog because “it's a big project”: breaking changes, async API rewrites, unknown unknowns. Classic deferred-maintenance reasoning.

Then I ran a full security audit and counted the CVEs sitting in my production next package: 14 high-severity vulnerabilities. That changed the calculation.

This post covers what those CVEs actually were, what risk they posed to a real SaaS app, and every code change I had to make to get from Next.js 14 to 15 on a live App Router codebase.

The vulnerability landscape

Every one of the 14 CVEs was fixed somewhere in the 15.5.x release series. Here's what I was sitting on:

CategoryCVEsFixed in
HTTP request smuggling (rewrites)115.5.13
Cache poisoning (Middleware/Proxy redirects, RSC collisions, RSC responses)315.5.16
XSS (CSP nonce leak, beforeInteractive untrusted input)215.5.16
SSRF (WebSocket upgrade handling)115.5.16
Auth bypass (Pages Router i18n Middleware bypass)115.5.16
DoS (Server Components x 2, Image Optimizer, HTTP deserialization, unbounded disk cache)615.0.8 to 15.5.16

Cache poisoning via Middleware redirects: if your middleware issues redirects based on request headers (e.g., auth gating), a crafted request could poison the CDN/proxy cache with the wrong response for legitimate users. I use Edge middleware for auth, so this was directly applicable.

SSRF via WebSocket upgrade: Next.js 14 would follow upgrade headers through internal proxying without proper validation. If you expose any WebSocket endpoints, an attacker could coerce the server into making requests to internal services.

HTTP request smuggling in rewrites: next.config.js rewrite rules that use user-controlled destination paths could be exploited for request smuggling between the Next.js layer and whatever sits behind it (nginx, Coolify, etc.).

DoS via Server Components: two separate advisories, both allowing an attacker to trigger unbounded work in the React Server Components renderer with a crafted request. No auth required.

Why I'd deferred it

Next.js 15 introduced several breaking changes that require touching a lot of files:

  • cookies() and headers() from next/headers became async
  • params and searchParams in route handlers became Promises
  • The default fetch() caching behavior flipped from force-cache to no-store

None of these are hard to fix individually. The problem is they're pervasive: you have to find every callsite, understand the execution context (server component? route handler? client component?), and apply the right fix. On a codebase with 50+ files in src/app/, that's a real afternoon of work with real regression risk.

The audit: what actually needed changing

cookies() and headers() calls, 5 locations

// Next.js 14
cookies().set('auth_token', token, { httpOnly: true, /* ... */ });
const cookieStore = cookies();
const signature = headers().get('stripe-signature');
 
// Next.js 15
(await cookies()).set('auth_token', token, { httpOnly: true, /* ... */ });
const cookieStore = await cookies();
const signature = (await headers()).get('stripe-signature');

Files: api/auth/login, api/auth/logout, api/auth/register, api/stripe/webhook, admin/page.tsx

Route handler params, 7 handlers in 2 files

// Next.js 14
export async function GET(req, { params }: { params: { path?: string[] } }) {
  const pathSegments = params.path || [];
 
// Next.js 15
export async function GET(req, { params }: { params: Promise<{ path?: string[] }> }) {
  const { path } = await params;
  const pathSegments = path || [];

Files: api/python/[[...path]]/route.ts (5 handlers), api/alerts/preferences/[id]/route.ts (PUT, DELETE)

Client component params, 1 location

// Next.js 15 -- client components use React.use() to unwrap
import { use } from 'react';
export default function SharedPage({ params }: { params: Promise<{ payload: string }> }) {
  const { payload } = use(params);
  useEffect(() => { /* ... */ }, [payload]);
}

fetch() caching, no changes needed

Every server-side fetch already had cache: 'no-store' set explicitly. The default flip in Next.js 15 had no effect on me. If your codebase relied on implicit caching, this is the sneakiest breaking change: no build errors, just silent behavior differences in production.

What the build caught

After making all code changes and running npm install, the type system and build pipeline surfaced three additional issues, none related to the code changes themselves; all artifacts of upgrading a live Next.js installation.

1 -- Stale incremental TypeScript cache

Symptom: Type error: File '.next/types/app/accounts/layout.ts' not found.

Next.js 15 generates per-route TypeScript type stubs under .next/types/app/ during the type-check phase, then cleans them up. TypeScript's incremental build cache (tsconfig.tsbuildinfo) records those files as “seen.” On the next build, TypeScript loads its cache, looks for the stubs before Next.js has regenerated them, and fails.

Fix:

// tsconfig.json -- co-locate the build cache with the build artifacts
"tsBuildInfoFile": ".next/cache/tsconfig.tsbuildinfo"
// package.json -- always start from a clean TypeScript state
"prebuild": "rm -f tsconfig.tsbuildinfo .next/cache/tsconfig.tsbuildinfo"

2 -- Missing not-found.tsx breaks standalone build traces

Symptom: ENOENT: .next/server/app/_not-found/page.js.nft.json

With output: 'standalone', Next.js 15 expects an .nft.json trace file for the built-in _not-found page. Without a custom not-found.tsx, it doesn't generate it cleanly.

Fix: Add src/app/not-found.tsx with any valid default export.

3 -- Build worker OOM kills silently

Symptom: Compiled successfully followed by ENOENT: pages-manifest.json not found, no server output written at all.

Next.js 15 runs compilation in a forked build worker. When that worker hits Node's heap limit (~1.5 GB default), it dies without writing output. The parent process reports success; .next/server/ is never created. Hard to diagnose because it looks like a missing file, not an OOM crash.

Fix:

"build": "NODE_OPTIONS='--max-old-space-size=4096' next build"

Lessons

CVE debt compounds. The right default is to track major versions within 3 to 6 months of stable release.

The async APIs change was the right call. Synchronous cookies() and headers() looked like pure function calls but were reading from a live request context. Making them async is honest.

The fetch caching flip was the dangerous one. No build errors, just silent production behavior changes. Explicit cache: 'no-store' on every server-side fetch is good practice regardless of framework version.

React.use() is the right pattern for client component params. Not a workaround, the designed API for unwrapping server-provided Promises in client components.

Update, 27 July 2026

Seventeen days after publishing this, eight more advisories landed against the release series I had just migrated to. This site was running next@15.5.18 and was exposed to all eight:

SeverityAdvisoryIssue
HighGHSA-m99w-x7hq-7vfjDenial of service in App Router using Server Actions
HighGHSA-89xv-2m56-2m9xSSRF in Server Actions on custom servers
HighGHSA-p9j2-gv94-2wf4SSRF in rewrites via attacker-controlled destination hostname
ModerateGHSA-68g3-v927-f742Cache confusion of response bodies for requests with bodies
ModerateGHSA-4633-3j49-mh5qCache confusion on bodies containing invalid UTF-8 byte sequences
ModerateGHSA-4c39-4ccg-62r3Unbounded Server Action payload in the Edge runtime
ModerateGHSA-q8wf-6r8g-63chDenial of service in the Image Optimization API using SVGs
ModerateGHSA-955p-x3mx-jcvpUnauthenticated disclosure of internal Server Function endpoints

All eight are fixed in 15.5.21. This site is now on 15.5.22.

The correction I owe this post. The lesson above says the right default is to track major versions within 3 to 6 months of stable release. That is at best half the lesson, and it is the less useful half. Tracking majors would not have helped here: these are patch-level advisories against a series I had already migrated to, and the gap between a clean audit and being exposed again was under three weeks. Patch currency is a standing job, not a project that completes. A migration buys a clean npm audit for exactly as long as it takes the next advisory to land.

The part I was not checking at all. Two packages that Next.js pins internally, postcss and sharp, carried their own high-severity advisories, and upgrading the framework does not fix either one: Next 16 pins the same vulnerable postcss build. Those need an npm overrides entry. A third, brace-expansion, was the root of roughly thirty transitively affected dev-tooling packages through minimatch. npm audit had been reporting all of this the whole time and I had been reading only the next lines.

"overrides": {
  "brace-expansion": "^5.0.8",
  "postcss": "^8.5.23",
  "sharp": "^0.35.3"
}

Because overrides rejects comment keys, the reasoning for each one lives beside it in a root overridesRationale key, which npm ignores. Future me will want to know why these are pinned before removing them.

Read what the automated fix intends to do. npm audit fix --force picks whatever version clears the advisory, and that is not always a version you want. On a sibling project the same afternoon it offered to resolve a postcss advisory by installing next@9.3.3. The non-destructive npm audit fix is fine; the --force variant needs its plan read first.

Current state as of this update: npm audit reports zero vulnerabilities, 665 tests pass, and the build is green.


All changes described here were made to Stratbeacon, a trading signals SaaS built on Next.js App Router + FastAPI.