THE NEXT.JS ROAST
"A framework held together by hope and @ts-ignore"
I've seen some things in my time. I've reviewed codebases that made me question my career choices. But Next.js? Next.js made me question the fabric of reality itself.
Let's start with the numbers, because numbers don't lie (unlike the Next.js changelog):
- 32,000 commits over 9 years
- 1.17 MILLION lines of code in the repo
- 43 MEGABYTES of "compiled" dependencies just sitting there
- 2,623 instances of TypeScript type escapes (any, @ts-ignore, @ts-expect-error)
- 1,252 skipped tests
- 29,783 lines in a SINGLE FILE (more on this horror later)
This isn't a framework. This is what happens when you build a house by continuously adding rooms for 9 years without ever stopping to check if the foundation is made of cardboard.
But wait. It gets worse. So much worse.
BIT #01: THE SECURITY HALL OF SHAME
Ladies and gentlemen, I present to you: Next.js's security track record.
In 2025 ALONE, we have:
CVE-2025-66478 - CRITICAL (10.0/10)
"RCE in React Server Components - Deserialization of untrusted data allows remote code execution without authentication"
Read that again. REMOTE. CODE. EXECUTION. WITHOUT. AUTHENTICATION.
They called it "React2Shell" because apparently "NextPlsHackMe" was too on the nose.
CVE-2025-29927 - CRITICAL (9.1/10)
"Authorization Bypass in Middleware"
You know that middleware you're using to protect your admin routes? The one that checks if users are authenticated? Yeah, you could bypass it by adding a single HTTP header:
x-middleware-subrequest
That's it. That's the bypass. Someone at Vercel looked at middleware and thought "you know what would be cool? If we had a secret backdoor header that skips all security checks."
And this wasn't some edge case. This affected FOUR YEARS of releases:
- 11.1.4 through 15.2.2
- Every major version
- Every minor version
- Every production app running middleware
The advisory says "validation occurs in middleware." You mean the thing EVERYONE uses for auth? The thing the docs recommend for protecting routes? That middleware?
But wait, there's more!
CVE-2024-34351 - "SSRF in Server Actions"
CVE-2024-46982 - "Cache Poisoning in Pages Router"
CVE-2024-51479 - "Authorization Bypass via Pathname" (ANOTHER bypass!)
That's FIVE high-to-critical vulnerabilities in authentication/authorization. In a framework whose entire marketing pitch is "production-ready."
The best part? Looking at git log, there's not a single commit message containing "CVE" or "security" or "vulnerability". They're all buried in generic "fix" commits. Security through obscurity of commit messages. Brilliant.
BIT #2: THE 30,000 LINE FONT FILE
packages/font/src/google/index.ts:29
Let me type that again because you probably thought it was a typo:
TWENTY. NINE. THOUSAND. SEVEN HUNDRED. EIGHTY THREE. LINES.
For fonts. FONTS.
/**
* This is an autogenerated file by scripts/update-google-fonts.js
*/
Oh, it's autogenerated? That makes it WORSE.
Someone wrote a script that generates a 30,000 line TypeScript file. And then they looked at that script, looked at the output, and said "yeah, this is fine. Ship it."
Inside this file are 1,901 function declarations. One function per Google Font. Each font gets its own exported function with its own hardcoded type definitions.
export declare function ABeeZee<
T extends CssVariable | undefined = undefined,
>(options: {
weight: '400' | Array<'400'>
style?: 'normal' | 'italic' | Array<'normal' | 'italic'>
// ... 10 more lines
}): T extends undefined ? NextFont : NextFontWithVariable
export declare function ADLaM_Display<
T extends CssVariable | undefined = undefined,
>(options: {
weight: '400' | Array<'400'>
// ... same thing again
}
You know what normal frameworks do? They have ONE generic function:
loadFont(name: string, options: FontOptions): Font
But no. Next.js said "what if we made IDE autocomplete take 45 seconds to load, and also crashed TypeScript language servers on low-memory machines?"
git log shows this file has been touched 78 times. SEVENTY-EIGHT commits to a 30,000 line autogenerated file. Each time, Vercel Release Bot dutifully commits the regenerated monstrosity.
The best part? The last "maintainer" of this file:
Vercel Release Bot <88769842+vercel-release-bot@users.noreply.github.com>
A bot. The file has become self-perpetuating. No human reviews it. No human could review it. It's achieved sentience through sheer mass.
BIT #03: TYPESCRIPT THEATER - 2,623 TYPE ESCAPES
Next.js is written in TypeScript. This is technically true in the same way that a car with no engine is technically a vehicle.
2,623 instances of type escapes. Let me show you the greatest hits:
packages/next/src/trace/shared.ts:3
let _traceGlobals: Map<any, any> = (global as any)._traceGlobals
"as any" used THREE times in a single line. That's not code, that's a cry for help.
packages/next/src/server/next.ts:207-208
if ((server as any).logErrorWithOriginalStack) {
return (server as any).logErrorWithOriginalStack(err, type)
}
They're checking if a property exists... by casting to any. Type guards exist. Optional chaining exists. But why use those when you can just lobotomize the type system?
packages/next/src/server/web/sandbox/context.ts:437-449
// @ts-ignore the timeouts have weird types in the edge runtime
context.setInterval = ...
// @ts-ignore the timeouts have weird types in the edge runtime
context.clearInterval = ...
// @ts-ignore the timeouts have weird types in the edge runtime
context.setTimeout = ...
// @ts-ignore the timeouts have weird types in the edge runtime
context.clearTimeout = ...
FOUR @ts-ignores in a row with the SAME COMMENT. Copy-paste engineering at its finest. The comment says "weird types" - you work at Vercel. You have engineers. FIX THE TYPES.
packages/next/src/server/dev/hot-reloader-webpack.ts:850
// @ts-ignore webpack supports an array of watchOptions when using a multiCompiler
packages/next/src/server/dev/hot-reloader-webpack.ts:910
// @ts-ignore entry is always a function
packages/next/src/server/dev/hot-reloader-webpack.ts:1234
// @ts-ignore webpack 5
"webpack 5" is not an explanation. Webpack 5 has been out since 2020. That's FIVE YEARS of "@ts-ignore webpack 5" commits without ever fixing the underlying types.
And then there are the empty catch blocks:
packages/next/src/server/web/spec-extension/unstable-cache.ts:277
revalidationPromise.catch(() => {})
packages/next/src/server/next-server.ts:1384
}).catch(() => {})
packages/next/src/server/route-modules/route-module.ts:923
} catch (_) {}
Error handling? Never heard of her. Just swallow those exceptions like they're free samples at Costco.
The entire codebase is TypeScript cosplay. It's wearing the costume, but it has no idea what TypeScript is actually supposed to do.
BIT #04: REVERT REVERT REVERT REVERT
git log --oneline --all | grep -i "revert" reveals the development philosophy at Vercel: ship first, ask questions never, revert when production catches fire.
But it's not just reverts. It's REVERTS OF REVERTS.
f797fa2af2: Revert "Revert "Turbopack: remove Asset supertrait from Module trait..."
Oh, you wanted to remove it? Then you reverted. Then you re-reverted. Make up your mind.
But THIS. This is the crown jewel:
9ba29b4125: Revert "Revert "Revert "Revert "Add a --webpack flag and default --turbopack to true (#84216)"""" (#84394)
Count them. FOUR LEVELS OF REVERT.
Let me trace the logic here:
- Someone added a webpack flag (84216)
- Someone reverted it
- Someone reverted the revert
- Someone reverted THAT
- Someone reverted THAT TOO
That's not version control. That's a Git-based anxiety disorder. At some point, the team should have just had a meeting. Instead, they played commit ping-pong until someone gave up.
607cce95f9: Revert "Revert "[Breaking] Bump minimum Node.js version to >=20.9.0"
Breaking change tennis! Do we support Node 20? Don't we? WHO KNOWS?
53ff410ee1: Revert "Revert "Initial MCP implementation (#81770)" (#82217)"
The MCP implementation got reverted and unreverted. MCP is Model Context Protocol. They couldn't decide if they wanted AI integration in their framework. TWICE.
e4131ff63f: Partially revert the revert in cookies.ts
"Partially." They couldn't even fully commit to the uncommitment.
This is what happens when your deployment pipeline is faster than your decision-making process. You can ship to production before anyone has time to ask "should we ship this to production?"
BIT #05: 1,252 SKIPPED TESTS (AKA "WE'LL FIX IT LATER")
1,252 tests marked with skip, xit, xtest, or describe.skip.
That's not a test suite. That's a graveyard of broken promises.
Let's look at some of the excuses:
test/e2e/app-dir/app-prefetch/prefetching.test.ts:18:
// TODO: re-enable for dev after
// https://vercel.slack.com/archives/C035J346QQL/p1663822388387959
// is resolved (Sep 22nd 2022)
September 22nd, 2022. It's now December 2025. That's THREE YEARS AND THREE MONTHS.
The link is to a SLACK MESSAGE. Not a GitHub issue. Not a Jira ticket. A Slack message. Which is probably in a channel that's been archived. Or deleted. Or buried under 47,000 "nice job!" emoji reactions.
git blame shows this TODO was last touched by Wyatt Johnson in April 2024. He saw this three-year-old TODO and said "looks fine to me" and shipped it.
test/development/app-dir/externalize-node-binary-browser-error.test.ts:8:
// FIXME: re-enable when we have a better implementation of node binary resolving
"When we have a better implementation." So never. Got it.
test/development/app-dir/cache-components-dev-cache-scope.test.ts:42:
// TODO CI is too flakey to run tests like this b/c timing cannot be controlled.
Translation: "We can't figure out how to write deterministic tests."
test/development/acceptance-app/ReactRefreshLogBox.test.ts:
38 FIXMEs
test/development/acceptance/ReactRefreshLogBox.test.ts:
34 FIXMEs
72 FIXMEs between two files. That's not a test file, that's a TODO list that masquerades as tests.
test/development/app-dir/ssr-in-rsc/ssr-in-rsc.test.ts:198:
"source": "<FIXME-nextjs-internal-source>"
They put FIXME IN THE EXPECTED TEST OUTPUT. The test passes when it sees the word "FIXME". The test is asserting that the code is broken. And when the code stays broken, the test passes.
That's not testing. That's self-aware satire.
BIT #06: 43 MEGABYTES OF "COMPILED" DEPENDENCIES
packages/next/src/compiled/ - 685 JavaScript files, 43MB total
Next.js vendors its dependencies. This sounds responsible until you realize what it actually means: they copy-paste 43 megabytes of other people's code into their repository.
drwxr-xr-x 143 kareemelbahrawy staff 4576 Dec 10 16:04 .
drwxr-xr-x 3 kareemelbahrawy staff 96 Dec 10 16:04 @babel
drwxr-xr-x 5 kareemelbahrawy staff 160 Dec 10 16:04 @edge-runtime
drwxr-xr-x 3 kareemelbahrawy staff 96 Dec 10 16:04 @vercel
drwxr-xr-x 33 kareemelbahrawy staff 1056 Dec 10 16:04 babel
drwxr-xr-x 5 kareemelbahrawy staff 160 Dec 10 16:04 react-dom
...
143 directories. They've vendored:
- Babel (all of it)
- React DOM (including experimental versions)
- Webpack (the entire bundler)
- Various @edge-runtime packages
- crypto-browserify (because why not)
The largest files:
packages/next/src/compiled/webpack/bundle5.js:29 - Line 29 is a single line with 25,000+ characters of minified webpack code.
packages/next/src/compiled/react-dom/cjs/react-dom-client.development.js - 30,586 lines
They're not even using the production build. They've vendored the DEVELOPMENT build of React DOM. All those helpful dev warnings? Yeah, those are in your production node_modules.
And they're versioning all of this. Every React update means committing 30,000+ lines of minified JavaScript. Every Webpack patch means updating a blob that no human can read.
Why? "To ensure consistency." You know what else ensures consistency? Package lockfiles. They've been around since 2016. But no, let's check 43MB of node_modules into git like it's 2012 and we're still using SVN.
The cherry on top? They have TWO versions of React DOM vendored:
- react-dom (stable)
- react-dom-experimental (canary)
Both are fully committed. Because why have one 30,000 line file when you can have two?
BIT #07: THE GOD FILES
Every mature codebase has a few large files. But Next.js has FILES THAT HAVE ACHIEVED SENTIENCE:
packages/next/src/server/app-render/app-render.tsx:1
// The entire App Router rendering logic in ONE FILE. Every time you load a
// page, this 5,660-line function tree decides your fate.
packages/next/src/build/index.ts:1
// The build process. One file. 4,299 lines of "let me just add one more
// condition to this switch statement."
packages/next/src/server/base-server.ts:1
// The base class that BOTH the dev server AND the prod server inherit from.
// Any bug here is a bug everywhere.
packages/next/src/build/webpack-config.ts:1
// Webpack configuration. Nearly 3,000 lines of webpack config. The webpack
// team themselves would weep.
packages/next/src/shared/lib/router/router.ts:1
// The router. The thing that handles clicking links. Two and a half thousand
// lines to navigate between pages.
packages/next/src/client/components/segment-cache/cache.ts:1
// Cache logic. 2,292 lines of caching. What are they caching? Everything.
// How are they caching? Don't ask.
And these files? They're not modular. They're not split into concerns. They're just massive procedural waterfalls where every line depends on some global state set 3,000 lines above.
app-render.tsx has 29 TODOs still in it. Twenty-nine things they know are wrong but haven't fixed. In PRODUCTION RENDERING CODE.
The function renderToHTMLOrFlight alone is 800+ lines. That's one function. For reference, the ideal function length according to Clean Code is 20 lines. They exceeded that recommendation by 4,000%.
BIT #08: FOREVER EXPERIMENTAL
packages/next/src/server/config-shared.ts contains the configuration options.
The word "experimental" appears 31 times.
Let me introduce you to Next.js's favorite word:
experimental?: ExperimentalConfig
That's not a config option. That's a lifestyle.
What's in experimental? Let's see:
ppr?: ExperimentalPPRConfig
// Partial Pre-Rendering. Been "experimental" since 2023.
taint?: boolean
// "Enables experimental taint APIs in React"
// "Using this feature will enable react@experimental"
// So experimental it uses ANOTHER experimental thing.
testProxy?: boolean
// "fetch requests to be proxied to the experimental test proxy server"
// Experimental test proxy. For testing. Experimentally.
isExperimentalCompile?: boolean
// The compile step is... experimental?
And my favorite:
'"experimental-edge"': `@deprecated\n\nThis option is no longer experimental.
Use \`edge\` instead.`
They deprecated an experimental feature. It graduated from experimental to deprecated WITHOUT EVER BEING STABLE.
The config file also has this gem:
/** @deprecated Use `turbopack` instead */
turbo?: boolean
But ALSO:
/**
* Options for Turbopack. Temporarily also available as
* `experimental.turbo` for compatibility.
*/
turbopack?: TurbopackConfig
Turbo is deprecated in favor of turbopack. But experimental.turbo still exists "for compatibility." Which means if you have turbo: true AND turbopack: { }, both are parsed, and God only knows which one wins.
The entire Next.js configuration system is a monument to indecision. Every controversial feature gets an experimental flag. And experimental features never graduate. They just accumulate until your next.config.js looks like a scientific paper's methodology section.
BIT #09: THE FLAKY TEST CHRONICLES
Search "flaky" in git log and watch the despair unfold:
bd2c871eb5 Try to improve typed-routes test flakyness (#86512)
"Try to improve." Not "fix." Not "resolve." TRY.
bbfd5fb29c [test] Disable flaky prefetching.stale-times test (#86299)
Their solution to flaky tests: disable them.
8943cd0d37 [test] Deflake legacy-link-behavior (#85805)
"Deflake" is now a VERB at Vercel.
53e104259d Revert "Fix flakey overlay feedback test" (#84819)
They tried to fix a flaky test. It didn't work. Reverted.
5bb25f9bf4 Fix flakey overlay feedback test (#84662)
Same test. Different spelling of "flakey."
a33898da9d Add patchFileDelay to flaky test
Their fix for timing issues: add delays. Professional.
944bc94e4a [test] Disable flaky navigation test (#83828)
Navigation. The CORE FEATURE. Has flaky tests. Disabled.
c03aaee550 tests: disable flaky deployment test while investigating upstream
Blaming "upstream." Classic.
And from the test files themselves:
test/development/app-dir/cache-components-dev-cache-scope.test.ts:42:
// TODO CI is too flakey to run tests like this b/c timing cannot be
// controlled.
Too flakey for CI. But totally fine for production.
22e40eda59 add debug logs to flaky tests
Debug logs. To tests that randomly fail. So they can watch them randomly
fail with MORE INFORMATION.
The test suite is playing Russian roulette with every PR. Some days it passes. Some days it doesn't. Nobody knows why. The solution is never to fix the underlying timing issues - it's always to add delays, retry loops, or just disable the tests entirely.
1,712 test files. 1,252 skipped tests. That's a 73% skip rate on assertions. The tests that DO run are described as "flakey" in commit messages.
This is what happens when you ship faster than you test.
BIT #10: THE DEPRECATION CAROUSEL
Next.js doesn't just deprecate features. It deprecates entire ARCHITECTURAL DECISIONS and asks you to migrate while the new thing is still experimental.
packages/next/src/server/web/types.ts:10
/**
* @deprecated Use `ProxyConfig` instead. Middleware has been renamed to Proxy.
*/
export type MiddlewareConfig = ProxyConfig
THEY RENAMED MIDDLEWARE TO PROXY. In 2025. The thing that EVERY Next.js tutorial teaches. The thing that's central to every authentication system. They just renamed it.
Your middleware.ts? Yeah, that should be proxy.ts now. But don't worry, the old name still works... for now. Until it doesn't. In the next minor version. Or major. Or maybe a patch. Who knows.
packages/next/src/server/web/spec-extension/image-response.ts:2
/**
* @deprecated ImageResponse moved from "next/server" to "next/og" since
* Next.js 14, please import from "next/og" instead.
*/
They moved ImageResponse. Between import paths. For fun. Enjoy updating every OG image file in your codebase.
packages/next/src/server/web/spec-extension/fetch-event.ts:72
/**
* @deprecated The `request` is now the first parameter and the API is
* now async.
*/
They changed the function signature. The order of parameters. That's not a deprecation. That's a breaking change wearing a deprecation costume.
packages/next/src/server/web/spec-extension/request.ts:85
/**
* @deprecated
* `page` has been deprecated in favour of `URLPattern`.
*/
URLPattern. A standard so new that Safari just got support in 2024. Your "deprecated" solution is to use bleeding-edge browser APIs.
And my absolute favorite:
packages/next/src/bin/next.ts:94
`--inspect` flag is deprecated. Use env variable NODE_OPTIONS instead:
NODE_OPTIONS='--inspect' next ${commandName}
They deprecated a CLI FLAG. A convenience feature. Use environment variables instead. Because that's more convenient. Definitely.
Using Next.js means maintaining a mental changelog of "which way are we doing this NOW?" Every tutorial is outdated the moment you read it. Every Stack Overflow answer is wrong by the time you implement it.
The only constant is change. And deprecation warnings.
BIT #11: THE USUAL SUSPECTS
git log --all --format="%an" | sort | uniq -c | sort -rn | head -20
3672 Tobias Koppers
3464 JJ Kasper
3132 Tim Neutkens
2706 vercel-release-bot
1542 Jiachi Liu
Let's talk about these names.
- TOBIAS KOPPERS - 3,672 commits (11.5% of ALL commits)
Tobias is the creator of Webpack. He works at Vercel now. He's been committing to Next.js. Which explains why there are 2,884 lines in webpack-config.ts. He knows where the bodies are buried because he buried them himself.
- JJ KASPER - 3,464 commits
JJ has touched almost everything. If there's a bug, there's a decent chance JJ wrote it. If there's a fix, there's a decent chance JJ wrote that too. He's playing both sides of every issue.
- TIM NEUTKENS - 3,132 commits
Tim is the co-creator of Next.js. He's been here since the beginning. He's watched this codebase grow from a simple React wrapper to a 1.17 million line enterprise. Does he feel pride? Or horror? Only Tim knows.
- VERCEL-RELEASE-BOT - 2,706 commits
A bot. The fourth most prolific "contributor" is A ROBOT. It mostly commits version bumps and that 30,000 line font file. At this point, the bot probably knows the codebase better than most humans.
- JIACHI LIU - 1,542 commits
Jiachi handles a lot of the App Router work. Which means Jiachi has stared into the abyss of app-render.tsx more than any human should. I hope Vercel provides therapy.
Together, these five accounts (including a bot) are responsible for 14,516 commits - 45% of the entire project history.
The bus factor is astronomical. If these four humans decided to take a vacation at the same time, Next.js development would halt completely. The bot would keep committing font updates to an empty office.
Also notable: "Steven" is in the top 20 with 615 commits. Just "Steven." No last name. No identifier. Just Steven. He could be anyone. He could be multiple people. We'll never know.
THE CLOSER: WHAT HAVE WE LEARNED?
Next.js is not a framework. Next.js is a cautionary tale about what happens when you prioritize shipping over stability, features over fundamentals, and marketing over maintenance.
Let's recap the carnage:
SECURITY:
- 2 critical RCE/auth bypass vulns in 2025 alone
- Auth bypass via a single HTTP header (affecting 4 years of releases)
- "React2Shell" - because your RSC deserializes untrusted data
CODE QUALITY:
- 30,000 line autogenerated font file
- 5,660 line rendering file with 29 TODOs
- 2,623 TypeScript type escapes
- Empty catch blocks swallowing errors like it's their job
TESTING:
- 1,252 skipped tests
- "Deflake" as a verb
- A test that passes when it sees the word "FIXME" in output
PROCESS:
- 4-level deep revert chains
- 43MB of vendored dependencies in git
- "Experimental" features that never graduate
And yet. AND YET. This is the framework that powers:
- TikTok
- Netflix
- Notion
- The New York Times
- Countless production applications
It works. Somehow. Like a car held together with duct tape and prayers, Next.js delivers millions of pages daily. The code is a disaster, but the product ships.
Is this the cost of moving fast? Is this what "production-ready" means in 2025? Is this the future we chose?
Every time you run npx create-next-app, you're voting yes.
Welcome to the stack. May your builds be successful and your middleware not get bypassed by a single header.