It’s not just you, Next.js is getting harder to use

It’s not just you, Next.js is getting harder to use

I wrote a blog post the other day about how Next.js Middleware can be useful for working around some of the restrictions imposed by server components. This led to some fun discussions in the world about whether this was a reasonable approach or if Next.js DX was just... bad.

From my perspective, Next.js’ App Router has two major problems that make it difficult to adopt:

  • You need to understand a lot about the internals to do seemingly basic tasks.
  • There are many ways to shoot yourself in the foot that are opt-out instead of opt-in.

To understand this better, let’s look at its predecessor, the Pages Router.

A quick look at the Pages Router

When I first learned about Next.js, the main “competitor” was Create React App (CRA). I was using CRA for all my projects, but I switched to Next.js for two reasons:

  • I liked file-based routing because it allowed me to write less boilerplate code.
  • Whenever I ran the dev server, CRA would open http://localhost:3000 (which gets annoying fast), and Next.js didn’t.

The second one is maybe a little silly, but to me, Next.js was:

React with better defaults.

And that’s all I really wanted. It wasn’t until later that I discovered the other features Next.js had. API routes were exciting as they gave me a serverless function without setting up any extra infra - super handy for things like “Contact Us” forms on a marketing site. getServerSideProps allowed me to run basic functions on the server before the page loaded.

Those concepts were powerful, but they were also simple.

An API route looked and acted a lot like every other route handler. If you had used Express or Cloudflare Workers, you can squint at a route handler and all the concepts you already knew translated. getServerSideProps was a little different, but once you understood how to get a request and the format of the response, it turned out to be pretty straightforward too.

The App Router release

The Next 13 release introduced the App Router, adding many new features. You had Server Components which allowed you to render your React components on the server and reduce the amount of data you needed to send to your client.

You had Layouts, which allowed you to define aspects of your UI shared by multiple routes and didn’t need to be re-rendered on every navigation.

Caching got… more sophisticated.

And while these features were interesting, the biggest loss was simplicity.

When a framework doesn’t do what you think it will do

A fairly universal experience as a developer is banging your head against the wall and yelling, “Why does this not work?”

Everyone’s been there, and it always sucks. For me, it’s even more painful if it feels like it’s not a bug in my code but a misunderstanding of how things are supposed to work.

You are no longer yelling, “Why does this not work?” but rather, “Why does this work… like that?”

The App Router, unfortunately, is full of these kinds of subtleties.

Let’s look back at my original issue: I just want to get the URL in a Server Component. Here’s an answer to a popular Github issue about the topic, and I’ll post part of it here:

If we take a step back, the question "Why can't I access pathname or current URL?" is part of a bigger question: "Why can't I access the complete request and response objects?"

Next.js is both a static and dynamic rendering framework that splits work into route segments. While exposing the request/response is very powerful, these objects are inherently dynamic and affect the entire route. This limits the framework's ability to implement current (caching and streaming) and future (Partial Prerendering) optimizations.

To address this challenge, we considered exposing the request object and tracking where it's being accessed (e.g. using a proxy). But this would make it harder to track how the methods were being used in your code base, and could lead developers to unintentionally opting into dynamic rendering.

Instead, we exposed specific methods from the Web Request API, unifying and optimizing each for usage in different contexts: Components, Server Actions, Route Handlers, and Middleware. These APIs allow the developer to explicitly opt into framework heuristics like dynamic rendering, and makes it easier for Next.js to track usage, breaking the work, and optimizing as much as possible.

For example, when using headers, the framework knows to opt into dynamic rendering to handle the request. Or, in the case of cookies, you can read cookies in the React render context, but only set cookies in a mutation context (e.g. Server Actions and Route Handlers) because cookies cannot be set once streaming starts.

For what it’s worth, this response is incredible. It’s well written, it helps me understand a lot of the underlying issues, and it gives me insight into the tradeoffs associated with different approaches that I absolutely didn’t think about.

That being said, if you are a developer and all you are trying to do is get the URL in a Server Component, you probably read this and left with 5 more things to Google before realizing you probably have to restructure your code.

This post summarizes my feelings about it:

It’s not that it’s necessarily incorrect - it’s unexpected.

That original post also mentioned a few other subtleties. One common footgun is in how cookies are handled. You can call cookies().set("key", "value") anywhere and it will type-check, but in some cases it will fail at runtime.

Compare these to the “old” way of doing things where you got a big request object and could do anything you wanted on the server, and it’s fair to say that there’s been a jump in complexity.

I also need to point out that the “on-by-default” aggressive caching is a rough experience. I’d argue that way more people expect to opt-in to caching rather than dig through a lot of documentation to figure out how to opt-out.

I’m sure other companies had similar issues to us, but at PropelAuth we often got bug reports that weren’t bugs but amounted to “You thought you made an API call, but you didn’t, and you are just reading a cached result.”

And all of this begs the question, who are these features and optimizations for?

It’s very hard to build a one-size-fits-all product

All of these features that I’m painting as overly complex do matter for some people. If you are building an e-commerce platform, for example, there are some great features here.

Your pages load faster because you send less data to the client. Your pages load faster because everything is aggressively cached. Your pages load faster because only parts of the page need to re-render when the user navigates to a new page. And in the e-commerce world, faster page loads means more money, so you would absolutely take the tradeoff of a more complex framework for them.

But if I’m building a dashboard for my SaaS application… I don’t really care about any of that. I care way more about the speed at which I ship features, and all that complexity becomes a burden on my dev team.

My personal experience and frustrations with the App Router will be different than another person’s because we have different products, different use cases, and different resources. Speaking specifically as a person who spends a lot of time writing and helping other people write B2B SaaS applications, the App Router DX is a big step down from the Pages Router.

Is this inevitable for frameworks as they grow?

As products/frameworks grow, they tend to get more complicated. Customers ask for more things. Bigger customers ask for more specific things. Bigger customers pay more so you prioritize and build those more specific things.

Customers who previously loved the simplicity of it all get annoyed at how complicated things feel and… oh, look at that, a new framework has popped up that’s way simpler. We should all switch to that!

It’s challenging to avoid this, but one way to mitigate it is to not make everyone deal with the complexity that only some people need.

One of my biggest issues with the App Router was just this:

Next.js has officially recommended that you use the App Router since before it was honestly ready for production use. Next.js doesn’t have a recommendation on whether TypeScript, ESLint, or Tailwind are right for your project (despite providing defaults of Yes on TS/ESLint, No to Tailwind - sorry Tailwind fans), but absolutely believes you should be using the App Router.

The official React docs don’t share the same sentiment. They currently recommend the Pages Router and describe the App Router as a “Bleeding-edge React Framework.”

When you look at the App Router through that lens, it makes way more sense. Instead of thinking of it as the recommended default for React, you can think of it more like a beta release. The experience is more complicated and some things that were easy are now hard/impossible, but what else would you expect from something that’s still “Bleeding-edge?”

So when you are picking a framework for your next project, it’s worth recognizing that there are still many rough edges in the App Router. You might have better luck reaching for a different tool that’s more suited to your use case.