Air Force Logo
Thundercats Logo
All writing
next-jscvesmigrationsecurity

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

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

We'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 we ran a full security audit and counted the CVEs sitting in our 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 we 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 we were 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 – 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. We 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 rewritesnext.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 we'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 us. 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 – 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.


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