2025-09-05
A couple of years ago, Andrew Clark of the React team, posted this bombshell of a tweet:

This sent a strong signal throughout the React community: you should probably use Next.js. To credit Andrew, he does not mention Next.js by name, but it is the most popular React meta framework, and Andrew works for Vercel which develops Next.js.
I teach a three course on React in Dutch, and on the second day I teach my students how to create a simple Pokemon / Pokedex application. My students get to pick if they want to create an SPA in Vite, a Next.js application, and recently even a Tanstack Start application. This means I've written the same application in multiple different React stacks.
My experience with teaching Next.js is that some parts of the Next.js API are hard to learn. Why is that?
So I have some gripes with Next.js some are minor some are larger. Overall Next.js is still a very decent React meta framework. I like the React Server Component based app router, this blog is even written in it. So do not take my gripes as deal breakers!
So without further ado: here are my gripes with Next.js in no particular order, so feel free to skip and scroll back to chapters as you please.
Table of Contents
- 1. app router vs pages router
- 2. page.tsx everywhere
- 3. Access to searchparams and path params from nested server components
- 4. Typing search params when using PageProps
- 5. Streaming / No JavaScript / Suspense gotcha
- 6. Dynamic vs Static pages
- 7. Caching madness
- 8. Documentation: the missing golden path
- 9. Conclusion
1. app router vs pages router
Next.js supports two routers / modes:
pages router: the firstborn file based router that originally came with Next.js. The idea of the pages router is that your directory / file structure defines your routes.
Within a page.tsx the default export from the file is the component that gets rendered when the page is shown.
Within the page.tsx Next.js looks / tries to import specifically named functions to provide extra functionality.
For example: if you export a getServerSideProps() function, that function would run on the server, and make its results / return available in the component. You could use this to query the database / API and render the results server side.
app router: the new file based router on the block. This time powered by the magic of React Server Components (RSC). React Components that run on the server, that support async / and await. Allowing you to query your database / API from inside of a React component.
The support for RSC's pushes data loading into React and moves it away from Next.js specific APIs.
I have two gripes with Next.js having two routers. The first is: that Next.js does not outright declare that one router is the future and that the other router is the past. Instead they dance around the issue, on the getting started page at the time of writing. The old pages router is and I quote: "The original router, still supported and being improved." However further along in the documentation they provide a migration path from the pages router to the app router.
So which router should I choose for a new project?
Let me clarify: I'm not saying that the Next.js team does not support the old pages router, and create new improvements. I just want them to state on their documentation: hey pick the app router for new projects, and migrate away from the pages router.
While we are at it lets remove the app vs page documentation switch on the documentation of Next.js, and place the docs for the pages router on a subdomain or something.
It seems to me that the sun setting / incredible journey blogpost for the pages router looms ever closer on the horizon.
I short Next.js just declare the app router the winner already!
My second gripe: is that the name "app router" seems to imply that you should use it to make web applications, instead it makes it easier to write classic web sites.
Also why tie the "app router" to an "app" directory? Why not say: "Hey we have a new router it's called the "RSC router" when enabled you can use RSC in your pages directory. The router has even more new features: co-location, layouts, etc etc. It is enabled for all new projects, and in the Next.js config file you can switch between the two."
Followed by: "We are now renaming the old router to "legacy router", we will support and improve it until usage falls under X percent / N number of years.
This way there would be no "succession problem" and the counterintuitive term "app" is gone.
2. page.tsx everywhere
I dislike it when frameworks force me create tens / hundreds of files with the exact same name.
Both TanStack Start and Next.js file based routes system do this. In Next.js you will quickly have a lot of page.tsx files everywhere!
Having a lot of files with the exact same name is really annoying for two reasons:
Quickly navigating to a specific page in your code editor takes more time. This is the tooltip I see in Visual Studio Code when quick opening a file by name:

You have to press your keyboard's arrow button to get the correct page.tsx file and you have to pay close attention to the directory.
Alternatively you can start by typing in directory, but then you'd have to know the directory structure by heart. Pro tip: you can search by name of the page component, to alleviate this problem somewhat, but you then have to name your default exports!
In code review tools such as GitHub / GitLab / BitBucket having multiple files with the exact name doesn't help in making it easier to understand what has changed.
These tools often do not show the entire page.tsx file that has changed, only a small portion of the changes by default. Making sense of which page has actually changed then becomes harder.
I prefer giving my files unique names, I'd love support for something like this: PokemonList.page.tsx. This way the file autocomplete and code review tools have a human readable / understandable files.
The strange thing is that you used to be able to do this with the older page router. Via the pageExtensions configuration options, but sadly this no longer works!
In short: Next.js should make *.page.tsx the default extension, and in the documentation / examples teach people to use it like so: PokemonList.page.tsx
3. Access to searchparams and path params from nested server components
Search parameters (or query parameters) are the ?page=1&query=bulb part of the URL and are vital for creating sharable URLs.
Currently the way you access search params on a server component is from the page.tsx components props like so:
type Props = {
searchParams: Promise<{
query: string;
page: string;
}>;
};
export default async function PokemonListPage(props: Props) {
const searchParams = await props.searchParams;
const page = searchParams.page ?? '1';
const query = searchParams.query ?? '';
const pokemonPage = await getPokemons({ page, size: '12', query });
// render pokemon here
}But what if you have a server component that is a descendant of the page.tsx component? In that case the only thing you can do is prop drill the search params down from the page.tsx to that component. This is very annoying if you have to drill them N levels deep!
Alternatively you can make that component a client component via the 'use client' directive and use the useSearchParams hook, as useSearchParams only works in a client component. This however feels to me like a copout.
It would be great if the useSearchParams() hook worked on the server as well as on the client! Alternatively maybe a function could be exposed called searchParams() that gives server components a way to access them, like the headers() and cookies() functions, to access the HTTP request headers and HTTP cookies respectively.
Of course the same goes for getting path parameters from the URL such as the id of the pokemon in paths like /pokedex/:id. Just expose a pathParams() function or something.
In short: deeply nested server components should be able to access the search params and path params more easily, without the need for prop drilling or hacky cache based workarounds.
4. Typing search params when using PageProps
In a push to make routes more type safe you can now use the PageProps helper. It will analyze which path params you have in your URL and you will get type safety on them.
This way if you have a path like: /pokedex/:id and you accidentally use path.it instead of path.id you get a TypeScript error. Which is great!
The problem I have with PageProps is that it does not take into account the search parameters / query parameters: the ?page=1&query=bulb part of the URL.
The example below errors gives an error, when compared with the example of chapter 3 above:
export default async function PokemonListPage(props: PageProps<'/pokedex'>) {
const searchParams = await props.searchParams;
const page = searchParams.page ?? '1';
const query = searchParams.query ?? '';
/*
This line now gives a TypeScript error:
Argument of type '{ page: string; query: string | string[]; }' is
not assignable to parameter of type 'string | string[][] |
Record<string, string> | URLSearchParams | undefined'.
*/
const pokemonPage = await getPokemons({ page, size: '12', query });
// render pokemon here
}The reason is that the PageProps basically turns all search params into a Record<string, string> instead of having them be explicitly defined.
Wouldn't it be great if PageProps accepted a second generic type for the search params, like this:
type PokemonListPageSearchParams = Promise<{
query: string;
page: string;
}>;
export default async function PokemonListPage(
// IMPORTANT: PageProps does not accept a second parameter
// but I'm proposing that should in the future:
props: PageProps<'/pokedex', PokemonListPageSearchParams>
) {
// render pokemon here
}In short: search params should be part of the PageProps utility type.
5. Streaming / No JavaScript / Suspense gotcha
Next.js supports streaming content to the browser as you render on the server, which is a very cool feature.
During your journey on learning Next.js you may encounter streaming on pages such as: Chapter 9: Streaming on the learn Next.js pages, or you may hit this page in the docs: Streaming with Suspense
The docs make you think you can wrap this code:
<main className="w-full lg:max-w-4xl mt-10 mb-4 mx-1">
{children}
</main>With a Suspense, and have it work like before:
<Suspense fallback="Loading...">
<main className="w-full lg:max-w-4xl mt-10 mb-4 mx-1">
{children}
</main>
</Suspense>The cost of doing this is not made clear in the documentation. When you add a Suspense like this your application will no longer work when JavaScript is disabled in the browser! A user which has JavaScript disabled will only see the Loading... fallback.
What Next.js does is this:
It will send the Loading... fallback to the browser as soon as possible.
When the content is loaded / ready it will stream down a hidden div containing the actual content.
It will run a small client / browser side JavaScript script that replaces the Suspense fallback with the contents of the hidden div.
If you do not have JavaScript enabled in the browser however, step 3 cannot work! Resulting in the Loading... fallback never being replaced.
I feel that not mentioning that you need JavaScript on the browser for this to work is a glaring omission. Just point it out in the docs and let us make a well informed decision.
Now you might ask me: "Maarten when did you think the Suspense's fallback would be rendered?" From this point you can reason backwards. Eventually you will reach the conclusion: but of course Next.js needs to show the fallback somehow, and swap it at some point, and the most logical point would be at the users browser. This is kind of the whole point of the Suspense component.
In the end I can often reason my way out of why things work they way do in Next.js, the problem, I think, is that Next.js does not proactively tell me how the magic works. Leaving me to guess, and experience the unexpected.
To better support disabled JavaScript. Ideally want to be able to express this: "Next.js I want you to start streaming content at a place of my choosing, but send the page in order as I define it. This way the client side script is not needed, and the page works when JS is disabled."
They could make this a component called Streaming. Then there would be a nice difference between Streaming and Suspense. As for Suspense you do not mind if if the fallback is shown to the user.
I confess: I might be a bit pedantic about wanting to make a website that does not require JavaScript, in order for the user to be able to read basic textual content. How many users / organizations disable JavaScript these days anyway? But still: should JavaScript not be optional for content based websites?
6. Dynamic vs Static pages
Dynamic pages render at request time / on demand, whilst static pages render once at build time.
The idea being that static pages that do not change based on the user / time of day. Take for example a simple hard-coded text based "about" page, it can be static because it does not change. Your blog post page with comments should be dynamic, because comments can be added any time.
The tricky bit is that you as the developer do not mark pages as static or dynamic manually / in code, instead using certain Next.js API automagically does this for you.
A page becomes dynamic when using: headers(), cookies() or using the search params prop in a server component, and of course fetching with the 'no-store' option (this last one is anything but obvious!).
Here is the kicker: in development mode static pages are re-rendered every time. So whilst you are developing, you will see one behavior, and in production you see another. Which is very bug sensitive.
For example: you render a server component page which does a fetch request to a weather API, to show the current temperature at your office. If you refresh in development, you get the latest temperature. When running the build for production, the fetch request is done once at build time, and served like this for infinity.
The fact that you opt into dynamic pages via APIs makes it feel so magical, but also contradictive with how 'use client' and 'use server' works.
If you try to use client / browser APIs, you will get errors if the 'use client' directive is not present. Then why is there not a 'use static' or 'use dynamic' which prevents you from using accidentally using dynamic APIs?
A new directive called 'use cache' is in beta / canary at the time of writing, it has overlapping ideas with my proposed 'use static'. I think that the name 'use cache' is a bit confusing as it focusses on the "how" and not the "why" so I prefer something like a 'use static' directive instead.
That being said the 'use cache' seems very promising.
I understand that this means that the developer has to write more code, and has to think a little more about which type of page they are creating, but at least it would be explicit!
In short: I do not want to wade through pages and pages of documentation, just to understand if a page is going to be static or dynamic. I want to see it made more explicit in code instead.
7. Caching madness
Caching is a cesspool of complexity in Next.js If you want to understand how caching works in Next.js, you need to buy red string, a white board the size of the great wall of China, and a lot of patience.
The API surface is vast, you need to know:
That fetch has been monkey patched by Next.js to support caching. With obtuse cache options such as 'auto no cache', 'no-store' and 'force-cache'.
Try for a minute or two to think what each of these caching options do. Then read the docs on fetch, and see if you got them right. I have to look them up every single time I use them.
Also why monkey patch fetch? Just create a small Next.js wrapper around fetch instead. Make it explicit and do not taint my knowledge of who fetch, the browser API works.
I can picture seeing newer developers / AI blindly assume that cache is an option on fetch outside of Next.js
Willing to use scary sounding APIs like unstable_cache. Which are documented as if they are completely stable in the docs.
Side note: what does unstable even indicate, that the API is going to / might change in the future, or that the feature does not work at all?
That you can revalidate caches on the path level via revalidatePath. But also on a tag level via revalidateTag.
The connection() API which allows you to do something, and affect caching somehow... Just read this illuminating quote directly from the Next.js docs:
"Good to know: Although fetch requests are not cached by default, Next.js will prerender routes that have fetch requests and cache the HTML. If you want to guarantee a route is dynamic, use the connection API."
How on earth am I supposed to know what connection() does by just reading code it is used in? In my mind I associate the word connection with: database connections, http connections, ftp connections, and network connection basically, but not caching!
The various segment configuration options, which let you define const variables to overwrite caching behavior such as this banger; const fetchCache = 'default-no-store';
There are just so many caching related concepts to learn and internalize, making caching a minefield.
Caching is as the adage goes: the second hardest thing in programming, after naming variables. In Next.js I'm placing it firmly on the first place instead.
I'm glad that the Next.js team has seen that caching by default was not a good idea. The new 'use cache' directive seems like a nice improvement.
The previous caching philosophy, in which Next.js would cache everything for you automagically, was a lofty idea, admirable even, but very experimental, and in the future legacy code.
Move fast and break things sound like fun, but I prefer caution and a stable API.
Some of you might say: what about Remix / React Router. Now I say a bit jokingly: in this world there has never been an API that changed so much between versions as react-router.
I think this is systemic of the entire NPM ecosystem: a wanton lust for API changes, (and I'm guilty to for proposing many API changes in this post), and chasing "thought leadership".
8. Documentation: the missing golden path
This section could also be called: Next.js give us your opinions.
As stated in the app vs pages router section: the documentation does not always tell you what you should be using.
In the future caching with the proposed 'use cache' will probably follow the same path with respects to the old caching APIs such as revalidate and export const dynamic.
Next.js should start deprecating older ways to do things aggressively!
Another offender in this regard is the API vs DB story. You can query the database directly in your Next.js app, isn't that awesome! Well is it awesome Next.js?, or should we not do this at all...
Is direct access to the database a cool party trick or is it what you are supposed to do?
It boils down to this: Should Next.js be your back-end? Should it manage the database schema? Should it validate all incoming forms? Should it handle authentication and authorization? It technically can do all the stuff mentioned above, you just have to roll your own code, or hark in additional libraries.
The point is we do not really know what Next.js thinks it is from reading the documentation.
My personal view is this: consider Next.js to be your front-end. Write a robust traditional back-end API: which manages the database, handles form validation, caching, user authentication / authorization, and test it independently.
Write the back-end in Node.js, Java, PHP, Python, Ruby or whatever floats your boat. Then only allow Next.js to access the data / database via an API, be it REST, tRCP or GraphQL.
9. Conclusion
In my personal experience when looking at the GitHub issues, and from my own issues with Next.js, Next.js is very brittle.
Each time a version comes out: things seem to break subtly, or the documentation claims a piece of code works and it does not.
Each version of Next.js seems to have new grand ideas API wise, which are incompatible with the old API, or can live side by side with the old API.
In each version new experimental features are added to be enabled via the next.config.js file. Some experiments such as "turbo", languish for years before becoming stable, some experiments are so stable they appear in the documentation without any warnings at all!
Next.js is a complex framework and all software contains bugs, so bugs are to be expected, as are API changes, and new ideas.
That being said each every framework / library has a certain "dread factor" when upgrading. Next.js has a high dread factor for me, with each version increase I shudder at what is going to change / break next.
The more stable a library / framework is the more credits / points a library gets from me. When a dependency, that has been stable for a long time, has a big new idea, the library has the social credit for me to pull it off.
To many changes to many bugs and people say: "not this again". The API churn rate is high.
I hope Next.js loses its wild side a little, and becomes more more mature, and more cautious. I hope that Next.js deprecates the old ways of doing thinks more aggressively. I hope that Next.js tells us in which way it is supposed to be used.
To reiterate: I think Next.js is a fine React meta framework built by smarter people than me. I probably do not have all insights that they have, this post was just my 2 cents on thinks I'd like to see differently.
I hope you enjoyed reading this post, please share it where ever you can!
No AI was used during the creation of this blogpost.Comparing JavaScript Frameworks part 2: reactivityWhy I migrated from Jest to Vitest