Back to Blog

Engineering

Migrating Next.js 14 to 16 in a working production app

Next.js 14 had six high-severity advisories that needed clearing. The fix-in versions said 15.5.15 and above, but npm only ships up to 15.5.9. So the upgrade was 14 to 16, two majors in one push. Here is the diff that worked, the one error I hit, and what I would tell anyone facing the same jump.

S
Sarma
4 May 20269 min read
ShareLinkedInX

In May 2026 six high-severity Dependabot advisories on Sarmalink-AI all pointed at the same package: next. The fix-in versions all read like >=13.0.0 <15.5.15 or similar. The first instinct was to bump Next.js to the latest 14.x and call it a day. That does not work, and the reason teaches you something about how npm registries publish versions.

The version trap

Run npm view next versions --json and look at what is on the registry today.

Latest Next.js versions on npm vs advisory fix-in versions

Source: npm registry, GitHub Security Advisories database, May 2026

The latest 14.x on npm is 14.2.9. Latest 15.x is 15.5.9. Latest 16.x is 16.2.4.

Now look at the advisories[5]. Six different Next.js CVEs, with patched-in versions of: 15.5.10, 15.5.13, 15.5.14, 15.5.15. None of those exist on npm, because Vercel rolled the patches into the 16.x release stream. There is no public 15.5.10. The upgrade path is not "stay on 15", it is "go to 16."

This is unusual but not unheard of. When a major version cuts close to a security cycle, the patched version often skips the previous major entirely. It catches teams who pin to the previous-major-latest and assume they are safe.

What I changed

Three direct dependency bumps in package.json:

json
{ "dependencies": { "next": "^16", "react": "^19", "react-dom": "^19" }, "devDependencies": { "eslint": "^9", "eslint-config-next": "^16", "@types/react": "^19", "@types/react-dom": "^19" } }

Notice the eslint bump. eslint-config-next@^16 peer-depends on eslint@>=9[4], and the project was on eslint 8. I caught that as an ERESOLVE error during install. Fix: bump eslint to 9 in the same PR. Do not reach for --legacy-peer-deps unless you have a reason. The peer dependency exists because the new flat-config style in eslint 9 is meaningfully different from eslint 8 in ways that matter for Next.js plugins.

The one breaking change in app code

Out of a 200-file Next.js codebase, exactly one file required code changes: a route handler with dynamic params.

Before:

ts
export async function GET( _req: Request, { params }: { params: { id: string } }, ) { const { id } = params // ... }

After:

ts
export async function GET( _req: Request, { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params // ... }

In Next.js 15 onward, the params argument to dynamic route handlers is a Promise[3]. You must await it. The Next typegen catches it during build, with an error of:

Types of property 'GET' are incompatible. Type '{ params: Promise<{ id: string; }>; }' is not assignable to type '{ params: { id: string; }; }'.

The same change applies to cookies(), headers(), and searchParams in App Router pages. If your code uses any of those synchronously, you have more work. For a route-handler-heavy codebase like the SarmaLink-AI proxy, the only place I had to touch was app/api/v1/manus/tasks/[id]/route.ts.

The thing that surprised me

The build passed first try.

I expected at least three or four migration issues: caching changes, fetch-cache defaults, server-action serialisation, the unstable_* API renames. I had time set aside to grind through them.

Nothing broke. The build was clean, the production deploy was clean, and after a sanity check on the chat endpoint and the v1 OpenAI-compat proxy, everything streamed correctly.

Three reasons that worked, in retrospect.

The codebase is small. ~30 API routes, ~10 pages, no server actions in production. The smaller the surface, the less migration friction.

It uses standard patterns. Every route handler is a normal async function GET(req) with Response or NextResponse return. No magic, no experimental_*, no PPR. Plain Next.js code is the most upgradable Next.js code.

The tests covered the critical paths. The Vitest suite covers the failover ladder, the auto-router, and the OpenAI-compat proxy. Running them after the bump would have caught any quiet regression. They all passed.

If your Next.js codebase is bigger or uses more cutting-edge features, your mileage will vary, sometimes badly. The Next.js migration guide[1] is the source of truth.

What I learned about the upgrade decision

The decision tree for "should I upgrade now" is simpler than people make it:

  1. Are there high or critical security advisories against your current version? Yes: upgrade. Stop reading.
  2. Is the upgrade path well-trodden (your version is at least one minor old, the target version has been out for at least one release cycle)? Yes: upgrade in a feature branch, run the build, ship it.
  3. Are there breaking changes you actually use? If yes, plan the changes. If no, just bump.

For Sarmalink-AI on May 2026, the answer to (1) was yes, six times. Everything else is implementation detail.

The full diff

The commit on main is f300f09[2]. One-line message: security: bump next 14→16, react 18→19, eslint 8→9 — closes 6 high-severity Next.js advisories; await params in /api/v1/manus/tasks/[id].

Three files changed: package.json, package-lock.json, the one route handler. Total: 4 files, 1494 insertions, 621 deletions (mostly lock file). No code logic changes outside the route handler.

What I would tell anyone facing the same jump

Three things.

Read your security tab first. It tells you not just what is broken but what versions actually fix it. A 5-minute scan saves hours of "should I upgrade or not" deliberation.

Try the upgrade in a feature branch. Run npm install, then npm run build, then npx tsc --noEmit. Three commands, ten minutes, and you know if the upgrade is small or huge.

Trust the official migration guide more than the LLM. Major-version migrations have authoritative docs. Read those first, ask the language model second.

The Next.js upgrade for Sarmalink-AI took about 25 minutes from "open package.json" to "merged to main". Yours might be longer, but the structure of the work is the same: bump deps, fix the one breaking change the build complains about, run tests, ship.

---

A note on this post

The numbers, code excerpts, and commit hashes referenced are real and verifiable in the linked repository. Charts are rendered from the data tables shown above each chart. Where the post draws on third-party advisories or maintainer announcements, citations link to the primary source.

References

  1. [1]

    Next.js 16 release notes

    https://nextjs.org/blog
  2. [2]

    Sarmalink-ai commit f300f09: bump next 14→16, react 18→19, eslint 8→9

    https://github.com/sarmakska/Sarmalink-ai/commit/f300f09
  3. [3]

    Next.js 15 upgrade guide — async request APIs

    https://nextjs.org/docs/app/guides/upgrading/version-15
  4. [4]

    eslint-config-next peer dependencies

    https://www.npmjs.com/package/eslint-config-next
  5. [5]
S

Sarma

Independent software engineer — AI systems, automation platforms, and modern infrastructure.

More in Engineering

Work with Sarma

Have a project in mind?

I take on a small number of projects each quarter — AI systems, automation, infrastructure, and full-stack engineering.

Get in touch