Press enter or click to view image in full size
It is hard to find time to write, especially when you have more responsibilities and family to take care of. But when the CEO of a company as big as Vercel asks for genuine feedback, it cannot be summarized in a Tweet. So, here goes my full response:
I have reservations against using or recommending Next.js as a general purpose framework for React projects. If I do so, then I feel I am not doing justice to software architecture. I wrote about my challenges 3–4 years ago after adopting it for many products:
This is not a critique of React or of Vercel as a company. Vercel has built an excellent platform — from hosting to V0 to sponsoring great OSS projects. My critique is strictly about Next.js as an abstraction, and why its mental model and architecture often get in the way of building sustainable systems.
If you do not care about software architecture or good software engineering then, you should stop here. You should also stop if the laws mentioned below are ridiculous in your opinion.
Laws of Software Abstraction
You build an abstraction only when one of the two requirements are met:
- The underlying system is complex and you need to hide its implementation details. (Like HTTP protocol over TCP protocol where I do not have to think about binary serialization.)
- The underlying system is incomplete and you need to add new capability. (Like TCP protocol abstracting and providing guarantees over connection-less IP protocol).
If abstraction doesn’t satisfy any of the above, then that abstraction is faulty or a case of an over-abstraction. Next.js repeatedly violates this principle. Its abstractions are very convenient on the surface, but they break down quickly when applied to larger, modular systems. The core question is then what problem Next.js is abstracting over.
A Good Framework
Framework is a mechanism to achieve the right level of abstraction. Framework is certainly a higher-level construct than library by taking control away from us and giving the right set of constraints and flow.
What is a good framework? There are three key traits to this. First, a good framework provides good layered-architecture. You can also call it an onion architecture where peeling each layer adds to the complexity while giving you more control to fine-tune. Second, a good framework provides you with a well-defined lifecycle. And, finally, a good framework prevents mistakes. It makes regular tasks boring.
Next.js is a framework. It is advertised as React-framework. But the community always perceives it as a fullstack framework. Here, I agree that it is a community fault and a skill issue. When you author a framework, your job is not just to provide a piece of code. You have to also provide proper documentation and right guidelines. The Next team falls short of right advice and I sometimes fear that they implicitly nudge developers towards the gains for their platform.
I will use Complexity and Composability of the Next.js as a basis for my analysis. I have already written in great detail about these aspects in an earlier article. It is pointless to elaborate again in detail and so will keep it short.
Complexity and the mental model
Next has crammed all the rendering modes aka execution context — CSR, SSR, SSG and ISR into one. And, then there is streaming on top of it. The wrong advice starts from here that everything needs SSR. Sure, you do need a server for authentication and many related activities but not always for rendering. How often are you building Amazon where every page has a public as well as authenticated view? The default rendering is server-side unless you opt-out explicitly.
In my mental model, I really need to be aware of the time and runtime axis — basically where am I? Am I in the build-cycle (very ahead of time), server-cycle or am I in the middle of client-cycle? Instead of simple lifecycle, Next.js imposes special functions. These are implicit and compiler-driven that application-driven. They are magical entry points that never compose well. In the long run, reasoning about such systems is not easy. File-system based routing is okay, but still prone to conflicts and errors.
Yes, probably it is my skill issue but shouldn’t the framework prevent me from making mistakes or at the least make things very obvious and boring for me? I want to rely on a framework to guide me and not dabble myself into its complexity and documentation.
TLDR; In software engineering, clarity of where code runs and how systems compose is fundamental. Next.js blurs these lines. Instead of one simple mental model, developers have to juggle build vs request vs client vs server, with special-case rules and hidden constraints.
The result is not disposable interfaces but pet-groomed code-bases that are expensive to build and maintain.
Composable Software
I can live with the complexity and that’s what I get paid for when I am consulting but when I am working on Enterprise software the story is different. I have six major pain points:
- Layered Architecture & Coupling
- Pluggable Architecture
- 12-Factor & Environment Variables
- Lifecycle & Initialization
- Custom Server & Middleware
- Packaging & Reusability
Let’s first talk about layered architecture. Next.js is built on four core pillars:
It is four core things coming together — CLI/Scaffolding, Bundler/Compiler, Router and a Runtime supplementing React.js.
In a well-layered system, each of these could be substituted or extended. In Next.js, that separation does not exist.
- Replace the router? Theoretically possible, but ergonomically painful.
- Swap the bundler? Technically yes, but brittle and unsupported.
- Use a different CLI? Only with significant friction.
This tight coupling means I cannot “peek under” one layer without disturbing the others. In enterprise systems, this breaks a core tenet of software engineering: loose coupling and well-defined interfaces.
But, you decide for yourself! Is Next.js layered enough?? Is loose-coupling no longer a part of good software engineering? Is that not a tenet worthy of pursuing? Layered architecture is important and has evolved after decades of software engineering wisdom. Loose coupling is critical. In large-scale software systems, if I cannot experiment and swap out one part for another, then I have a major innovation problem. I need to arrange my system that grants a margin for innovation without disturbing everything. As a staff engineer, I want to balance business, learning and opportunities for innovation in my team. No CTO is ever going to allow me to rewrite a running-system.
The tight coupling haunts in unexpected ways. For one project, it took us 6–7 months of Webpack to Vite migration — development, testing, rollbacks, fixes and then final push to production.
Maybe Next is trying to become a Rails or Django or even Spring Boot of the JavaScript/TypeScript world! But if yes, I have my further pain areas.
Pluggable Architecture
Take a very naive Keystatic CMS integration for example. This is Astro integration and this is Next integration. Astro provides me with a clear plugin interfaces: a CMS integration can register itself without leaking into application code. With Next.js, the opposite happens:
In Next.js, I must explicitly set up admin pages, leaking CMS details into my codebase.
That’s not really pluggable architecture. Even if it’s just a line or a file, it seeps into every part of the codebase. This is an abstraction leak. I want to build an instrumentation library. I cannot simply do it via Next.js configuration. I have to add configuration and then ask my library users to add instrumentation.ts file and then hope that my users use it correctly. Another abstraction leaked.
12-Factor & Environment Variables
It is three years since my question on StackOverflow and the framework still doesn’t solve this problem out of the box for me:
Next.js blurs build-time and runtime environment variables. Next.js team did not provide better guidance on their usage. They prevent 12-factor deployment. Shouldn’t this be a de-facto thing for a framework? It doesn’t matter if it is UI framework or not.
Yes, you can do it in theory but you can no longer do it in practice. Many supporting libraries are now dependent on usage of NEXT_PUBLIC_* variables. Now, even if I ensure that my code doesn’t use build time variables, I have to build my images/containers for each environment due to these libraries. There is no way out. Needing anything beyond NODE_ENV is questionable.
If you ask C/C++ developer, they will instantly tell you that build time variables is akin to build time Macro expansion. They are to be used carefully and for advanced cases and 100% static rendering.
For any other rendering model, you should just inline them in the initial payload without any performance penalty; literally zero.
It is unimaginable why Next would make this as a very prominent feature. A framework has a job to protect from accidental mistakes.
One time initialization
Even after 15 major iterations, there is no way official way for startup or one time execution event. I end up using unique symbol on globalThis object to ensure singleton as I am not sure how the finally bundled output looks like.
I have seen at least half dozen projects now that use `instrumentation.ts` file for one time code initialization. Is that framework really doing what it is supposed to be doing?
Frameworks, by definition, take control away from you and provide a lifecycle. Hono.js has a lifecycle and so does Fastify, in fact one of the best well laid out lifecycle.
If Next.js was only about rendering problems, then it should have been a simple adapter for already well-established frameworks because it is clearly not handling all concerns which brings up my next point.
Custom Server and Middlewares
Custom server is literally a namesake. We tried it. We used it with Koa. We used it with Fastify and the mental gymnastics required to achieve this is simply not worth it. It works until it doesn’t work. Some features are simply not available.
Then comes the Middlewares. It took 15 iterations for middlewares to support Node.js runtime in middleware. The only option was some esoteric Edge runtime till that time. Middleware is a solved problem from the days of Express. If something claims to be a framework, then these are solved problems. There was no point in reinventing the wheel.
Reiterating again that if Next.js only solves the rendering problem and as such it should be an adapter/plugin/middleware for well-established frameworks.
I have built my own SSR with Webpack in early days. I used Vite in Backend integration that was not a Node.js runtime. I am using Vite SSR with Fastify. And, now I am experimenting with Parcel’s new React Server Components. These are all well thought our composable solutions that plug well into existing ecosystem readily. I did not need any special provision in my docker image. I did not have to split my web application into multiple services because Next.js did not provide better abstractions for other concerns of the web applications). It just works.
I deeply admire @pilcrowonpaper and the work he did on Lucia Auth. I never met him in person. I do not even know his name. But I know that he did not hesitate to kill his own solution and turn it into set of guidelines because architecturally that made more sense. Read below to get more idea:
This is what a Next.js should have been. It could have solved a complex problem of hydration and streaming but it did not need a full framework to do this.
Application Packaging
I will stop here with this final point. I still have an unsolved question:
All I can do is that I publish a bunch of functions and hooks, hope that my users will do the right thing at a right place by following my documentation because, Next.js doesn’t provide a right level of abstraction nor does it provide good system of application composition and also doesn’t provide application lifecycle to hook into.
As I said, my list is long and I can go on and on:
- Inflexible file system routing.
- Magical middleware and its weird behavior when accessing public files, API routes and changes according to rendering mode.
- Caching shenanigans.
- Anything beyond HTTP and tRPC.
- Rigid layout system with lots of conditions.
- Missing notion of stateful request-response paradigm.
- Nothing about threads, background jobs or queues.
- Poor man’s API system.
Individually, when you look at them, it looks like it supports everything and some limitations might be tolerable. However, collectively, it paints a picture of a framework that tightly controls rendering but leaves every other concern half-solved.
The world beyond rendering
If you’ve made it this far, the most important critique is this: there’s a wide class of real-world applications that simply cannot be built within Next.js’ constraints. I’ve run into several such cases firsthand:
- Dynamic theming system (CRM use case).
Similar to WordPress or Ghost, the requirement was that users could upload a theme (CSS, templates, assets) without code changes or redeployment. Because Next.js tightly couples rendering to the build step, this was impossible. I ended up writing a custom JSX templating system outside of Next.js. - Multi-team modular platform.
A company needed multiple small applications, each owned by different teams. Ideally, a base app would be published as an NPM package and extended. Next.js doesn’t support this pattern. The workaround was scaffolding tools and codemods for every upgrade — after nine iterations, the company removed Next.js from their tech radar. - Enterprise release pipelines (finance).
In regulated finance environments, Docker images must be promoted from integration → staging → production without rebuilds. Next.js assumes flexible redeploys, making this impossible. The framework’s deployment model simply does not fit enterprise-grade release constraints. - Dual licensing / open-core product.
I needed to build a product where the core was open (free of authentication, logging, and telemetry) while the proprietary version extended those features. Next.js’ monolithic app model made this impossible without duplicating the entire application. I ultimately had to build two separate code-bases and use git submodules.
Revisiting Laws of Abstraction and Next.js
So, revisiting my laws about abstraction. So what problem is Next.js really solving? At best, it abstracts over the bundler and React’s rendering lifecycle. But these abstractions are leaky, hard to reason about, and introduce more complexity than they remove. For most applications, the tradeoff is not worth it:
- It’s not as simple and transparent as Vite.
- It’s not as comprehensive as Phoenix or Rails.
- It doesn’t support modular, large-scale architecture in practice.
That leaves only three possibilities:
Next.js fails essential software engineering criteria. My understanding of architecture is misplaced. Or there’s a “grand vision” of Next.js that I — and many others — have yet to see.
Conclusion
I am a web application architect, currently working as a principal/staff engineer, with nearly sixteen years of development experience. At this stage, I encounter two kinds of technology leaders, and often report directly to them. If a CTO says, “Everyone uses Next.js, let’s adopt it,” I’ll accept the decision and make it work. But if the other leader asks for my honest architectural assessment, this is what I share.
Next.js has certainly paid my bills, and I can deliver projects with it. But its rise resembles the boom of OOP: rapid adoption, strong enthusiasm, and an almost unquestioned belief in its magic. The challenge is that serious concerns are often difficult to raise in such an environment (Look at the original message; how everything is simply attributed to skill issue). Not my words but as far as I can remember, either of Paul Graham or Eric Raymond.
If Next.js positioned itself honestly as a UI rendering adapter, its design choices would make sense. But presented as a framework — fullstack or otherwise — it consistently falls short of established software engineering principles: composability, modularity, and architectural clarity.
I am not an extremist in my views. I am not a fan of micro frontends where you just ship over the air without real deployment. I am not a fan of DHH’s “no bundling, no compilation” approach. And I am certainly not a fan of Next.js’s approach to building user interfaces. What I do advocate is simplicity. I value bundling, but not magical bundling. I value compilation, but not compiler-driven and compiler-coupled lifecycles. I accept a framework taking control, but not without giving me a clear and reliable application lifecycle.
That is why, for me, Next.js is not truly a framework — it is a rendering tool dressed as one. And while it may serve many teams well enough, I cannot recommend it as the foundation for large-scale, well-architected applications. In software, simplicity endures. My goal here is not to prescribe a single alternative or declare winners, but to demystify the trade-offs I’ve observed in practice.
.png)

