Response to "Re-evaluating Next.js"
This is my response to David Lorenz's article titled "Re-evaluating Next.js: Did it go the wrong path?". David also made a Reddit post about his article that can be viewed here.
The reason for my response
I am responding to David's article because I frequently encounter similar complaints about React Server Components (RSCs), Server-Side Rendering (SSR), and the Next.js App Router on various social media platforms.
Going through David's article and addressing each of the criticisms is an effective way for me to consolidate all of my thoughts on the subject in one place.
Having this article as a reference will allow me to easily provide answers to many of the questions developers raise about these topics.
Since v13, Next.js tries to push Server-Side-Rendering to the fullest but it doesn't feel complementary anymore but instead seems to wanting to replace frontend. I mean this is cool in the use-case where you actually want that, say a contentful news page or an e-commerce shop. But for an extremely fluid, reactive application with beautiful user interaction, things got way more complicated.
I assume David is talking about the introduction of React Server Components (RSCs) in Next.js. However, RSCs are a React feature, not a Next.js feature. Additionally, RSCs are not the same thing as Server-Side Rendering (SSR). I plan to explore the differences between SSR and RSCs in more detail later in this article.
The primary goal of Next.js is to provide a comprehensive full stack framework to support React and has aligned itself with React's overall vision. It's not the other way around. React was never planning on being a client-only library. The React team was inspired by XHP which was a server component-oriented architecture used at Facebook. The people working on React were full stack developers and Facebook was (and still is) a server-centric company.
React is not aiming to replace the frontend with the backend. RSCs serve client components by componentizing the request/response model. They are not intended to replace client components, but rather to work alongside them as an additional layer. Client components will continue to play a crucial role in the development of React applications, and they work in the same way React components have always worked. If the React team didn't care about client components, they wouldn't be working on the React compiler.
The problem isn't SSR, at all. SSR is lovely and important. The problem is, that we are hyperfocused on enforcing SSR and right now it doesn't seem quite reasonable anymore. If Next.js could trigger onClick on the server, they probably would. And this is where it gets ridicolous: Solving a problem that didn't want to be solved. But let's get into actual issues.
As I mentioned, client components are not going anywhere. They will continue to play a crucial role in providing interactivity in React applications. The boundaries between server and client components are well-defined, with the client being the domain for interactivity.
The comment about server-side onClick
is a baseless exaggeration, and implying that the React team, which includes some of the best engineers in the industry, is trying to "solve a problem that didn't want to be solved" is simply inaccurate. The React team is making thoughtful, reasonable decisions about how React should work on the server, as in a component-oriented architecture, the server is a crucial part of a component's concern.
SSR vs RSC
There seems to be some confusion in David's article about the differences between SSR and RSCs. I see a lot of this confusion on social media too. So, I think it's important to go over SSR and how it relates to RSCs.
I recommend reading Josh Comeau's article on RSCs and SSR, but I will give a summary and use it to help support my thoughts.
SSR generates the initial HTML so that users don't have to stare at an empty white page while the JS bundles are downloaded and parsed. Client-side React then picks up where server-side React left off.
To further expand on SSR vs CSR (client-side rendering), we need to know a few concepts.
- First Paint is when the user is no longer staring at a blank white screen. The general layout has been rendered, but the content is still missing.
- Page Interactive is when React has been downloaded, and our application has been rendered/hydrated. Interactive elements are now fully responsive
- Content Paint is when the page now includes the stuff the user cares about. We've pulled the data from the database and rendered it in the UI.
The general flow is something like:
-
CSR
- javascript is downloaded
- shell is rendered
- then, “first paint” and “page interactive” happen at about the same time.
- A second round-trip network request for database query
- Render content
- Finally, “Content Painted”
-
SSR
- Immediately get a rendered shell
- “first paint”
- download javascript
- hydration
- Page Interactive
- A second round-trip network request for database query
- Render content
- Finally, “Content Painted”
That explanation should give a good idea about the benefits of SSR over CSR.
Furthermore, tools like getServerSideProps
and Remix's loader function improved things even more. Instead of requiring a second round-trip network request, the database work can be done on the initial request.
-
SSR with
getServerSideProps
:- DB query
- Render app
- First Paint AND Content Painted
- Download JavaScript
- Hydrate
- Page interactive
-
But these solutions have a few downsides:
- They only work at the route level.
- There is no standard.
- All React components will always hydrate on the client.
This is where React Server Components (RSCs) come in to help solve those downsides. RSCs can fetch data at the component level and they do not need to hydrate on the client since they are rendered on the server.
It might take a while until RSCs are the standard since they are only really used in App Router, but Remix will have them soon. Also, there is a framework called Waku that supports RSCs.
Here are some of the differences between RSCs and client components:
- RSCs only render on the server
- this is why react hooks don't work in RSCs
- RSCs are just an additional layer, they do not change behavior of client components
- Client components render on the server and the client
- Client components work the same way react components have always worked and that includes SSR
RSCs are like the skeleton and client components are the interactive muscle that surrounds the skeleton. As I said, RSCs and client components work alongside each other and serve different purposes.
Also, RSC's are similar to HTMX but they return JSX instead of HTML. The initial RSC content is included in the HTML payload. But RSCs are about more than just returning JSX, they allow for components on both sides.
React's focus on components has caused significant debate over the years. One of the first controversies was about JSX and "separation of concerns". While some developers initially struggled with JSX, many grew to appreciate it and were still able to look at React through an MVC perspective. However, with the introduction of RSCs, I think a shift towards a more component-centric mindset is necessary for developers to effectively adapt to these new changes.
With v13, Next.js started caching your internal API requests implicitly. Means: If you use fetch for grabbing weather data, you'll get old weather data the next time you fetch it. Thanks to automatic caching. Sure, you can disable it. But this is a breaking change and React itself has a known guideline that it's tailored for enterprise not having breaking changes like that (link to react docs). So even though React and Next are extremely close, Next didn't seem to adhere to the same design principles. Having such a caching mechanism is surely awesome, if you can enable it - either globally or partially. But I'm confused that amongst all architects, no one said: it shouldn't be enabled by default changing the way your application works in a world where probably most API requests should not be cached.
Wanting RSCs to be opt-in to caching (rather than opt-out) is a misunderstanding of how RSCs work in App Router. RSCs are prerendered by default and not dynamic, which I think is also the approach recommended by React. This will become even more important when partial prerendering comes out.
RSCs are prerendered by default but if we use dynamic functions (cookies()
, header()
, and searchParams
) in a server component, then it will automatically opt the whole route into dynamic rendering. Also, we can manually opt-out with options like noStore()
.
Most of the time, we will use on-demand revalidation like revalidatePath()
instead of dynamic rendering.
I think most of the complaints about caching are related to the client-side router cache. Currently, staletime is automatically set to 30 seconds
for dynamic rendering and I think people expect it to update immediately. There is a proposal to give the option to set staletime to 0 seconds
and I think this will help solve a lot of the caching issues.
In the legacy React docs about stability, I think this is an important paragraph: "However we think stability in the sense of “nothing changes” is overrated. It quickly turns into stagnation. Instead, we prefer the stability in the sense of “It is heavily used in production, and when something changes, there is a clear (and preferably automated) migration path.”
The whole thing lead to an answer in the form of explaining the caching (Vercel video on caching). There's a saying though: If you need to explain it, it's not intuitive enough. But the thing is: it was explained in the docs (which makes sense) so rather the saying is: If you need to explain it over and over again, it's definitely not the best solution. (Still, thanks for the video, it's a good one!).
As David said in the quote above, not only is caching explained in the docs, but Lee also made a video about it. I think this is evidence that developers seem to be struggling with caching in app router. I see a lot of complaints on social media as well. However, I think it's important to keep in mind that caching is notoriously difficult and it doesn't help that we have to think about caching on the server and the client.
Web development can be a complex and demanding field, often requiring extensive learning and skill development. Just because a topic requires a thorough explanation does not mean it's an inherently poor or counterintuitive approach.
While caching in App Router may not be perfect, it's relatively intuitive and makes sense once we get used to it. I doubt most developers will need to watch the video, but it's nice that Vercel is making videos explaining some of the more complex aspects of App Router. In many cases, developers may be overcomplicating the caching functionality, as it is designed to be quite straightforward and largely automated.
Also, experienced developers often come with a lot of preconceived notions about how things should work. If a developer starts with the mindset that RSCs should be dynamically rendered by default (and opt-in to caching), then it's going to be a bad experience and maybe even confusing at first. But, a good explanation along with hands-on experience will help overcome this. Maybe, new developers will struggle less than developers that have a lot of experience. Not just with caching, but RSCs too.
I have noticed that social media causes a lot of confusion as well. Someone will make a post complaining on Twitter, the post will go viral, and it turns out it was a misunderstanding or inaccurate. But now, thousands of people believe it.
Other controversial topics surrounding RSCs.
A lot of people think the naming of React "Server" Components doesn't fit because RSCs can be prerendered at build time. Some developers think RSCs should only be called server components if they run on a server dynamically at request time. But, this is a misunderstanding. RSC's are not called "server" components because of the machine they run on. They refer to pieces of software — the server and the client of the React protocol.
I think server components are probably the best name we have for this. From the perspective of the dev experience, they are React components on a server. We are always using a server for those components until the build. After that, the app can be deployed as files on a CDN or a full stack app.
As Dan Abramov pointed out, The better way to think about RSCs is that they "serve" our regular client components. The request/response can run either at deploy/build or on an actual server.
Many developers are also confused about "use client"
and "use server"
. They think about these directives as a way to say "this is a server component" and "this is a client component", but that is not how these directives work. Instead, they are entry points:
-
“use client”
marks a door from server to client. like a<script>
tag. -
“use server”
marks a door from client to server. like a REST endpoint.
The misunderstanding around these directives also caused some complaints about server components being the "default". They think we should have to write "use server"
for server components and not "use client"
for client components. But again, this is just a misunderstanding of how these directive work.
Server components have to be the default because they are the "root". It's part of the code that has to run earlier because it determines what is rendered next. It's the same reason HTML is the outer layer and the script tag is the inner layer. Then, "use client"
marks the entry point where the data flows to the client.
when I'm using Supabase and fetch data from it, it will always skip the cache - which usually makes sense but if Next had explicit caching, I could just put the result to the cache - oh well, in fact, you can, it's just hidden: next docs: react-cache-function but, again, comes with pitfalls: react docs: cache. So much to know for a rather simple thing to achieve.
This is not hidden. Not only is it clearly in the docs but I believe it's also in Lee's video.
Also, here is the example of the Pitfall mentioned:
export function Temperature({ cityData }) {
// 🚩 Wrong: Calling `cache` in component creates new `getWeekReport` for each render
const getWeekReport = cache(calculateWeekReport);
const report = getWeekReport(cityData);
// ...
}
We wouldn't want a new cache to get created on each rerender, but more importantly, we want that cache accessible outside of that component. For example, I would put cache()
on a Prisma query in it's own separate file.
So much to know for a rather simple thing to achieve.
I think this should be obvious to anyone that has a basic understanding of React. Also, this is clearly explained in the React docs and it's a React cache, not a Next.js cache.
The other problem about that caching is that there are a lot of additional things to consider. For example, if you want to force a page to refresh, you can actually call
router.refresh()
one the same page orrevalidatePath
. Unsure? Well that's easy, here's what the docs say:
This is because router.refresh()
only works on the client. useRouter()
is a react hook and is meant to be used for the client-side router cache.
On-demand revalidation using revalidatePath
is server side. Also, since it runs on the server, it purges the server and the client. We use it in a server action or an API route.
router.refresh()
likely won't get used as often and isn't meant to be used interchangeably with revalidatePath
. I could be wrong, but I don't think it's something developers will often be unsure about since they are both quite different.
Again, Next.js "solved" this by allowing you to add a loader.js at the level of the page you are loading. The problem is though: The loader has to be loaded - for the sake of performance. I'm not sure if I should laugh about this. The loader's goal, UX-wise, is to be instantaneous to provide user-feedback. Period.
Sure, we still load the loading UI, but I have not had this experience where loading.tsx
isn't perceived as instant. I use both loading.tsx
and suspense in server components and I don't have to wait around for loading skeletons.
Also, it's unlikely that many spinners and skeletons would result in a large data load, as these are generally lightweight elements.
Each organization in the Workspaces dashboard has an activity list. The ActivityList
is a server component that is wrapped in suspense.
Sure, this isn't a huge dashboard with a lot of spinners, but it's an example of using suspense with server components. Also, this is using a shadcn/ui
skeleton instead of a spinner.
The code looks something like this:
import { Info } from "../_components/info";
import {
ActivityList,
ActivityListSkeleton,
} from "./_components/activity-list";
import { Separator } from "@/components/ui/separator";
import { Suspense } from "react";
const ActivityPage = async () => {
return (
<div className="w-full">
<Info />
<Separator className="my-4" />
<Suspense fallback={<ActivityListSkeleton />}>
<ActivityList />
</Suspense>
</div>
);
};
export default ActivityPage;
Server actions were published without security advise
I don't wanna go too deep into that topic as I've made a video about it: YouTube: NextJS reveals Password. The thing is: If you use Server Actions as part of your component and load data with credential keys, the credentials are bridged via frontend without you noticing. Some people said in the video: Well, that's obvious because it's a closure. But in fact: It's not a normal closure. This isn't "standard javascript". Nothing here is obvious. Server Actions themselves aren't obvious because they're extracted from the actual component and in times where bundlers do the work and something like Server Actions are presented without further warning or advise considering that, I can expect that they're being extracted without passing my credentials.
There is a blog post written by Sebastian Markbåge talking about security in server actions.
While it's true that there are always security risks to consider, the use of RSCs and server actions generally reduce security risks.
This is an overview of how server actions work:
Whenever we add "use server"
to a server-side function and import it into a client component, it marks it as available to the client (an entry point to the server). That doesn't mean a function gets serialized and sent over the wire, instead, the client will get a URL string to that function and the client can use it to send a request to the server using RPC. It's a POST request. This is handled for us automatically and all we have to do is include "use server"
, import our server action or pass it as a prop, and just use it. We never see this URL string, but that's how it works under the hood.
It's important to add "use server"
even if we use the server action in a server component. Let's say we have a button in a server component and we want to use a server action when someone clicks that button. We still need a URL string since that button ends up on the client where it gets clicked. So it will only work if we include the "use server"
directive to the server action.
Furthermore, if we import a server action into a client component and forget to add "use server"
, it will import that function as code into the client. When we add "use server"
, it lets that function stay on the server.
When an SPA was slow it was pretty easy to debug. It was either the server response that took too long or something in your frontend code was rubbish. With the hybrid approach and the implicit caching mechanisms sometimes you're waiting and you have to dig deep to find the root cause - sometimes without any success.
Client-side React can be incredibly complex and difficult to debug. This is why so much effort is being put into the React compiler. RSCs help reduce a lot of that complexity.
One thing I noticed when converting Pages Router to App Router, I was able to remove a lot of code and it was more straightforward and easier to comprehend.
Debugging hasn't changed much in my experience. There is the usual hydration issues, but that's just SSR in general. Some common RSC-related issues were forgetting to add "use client" or trying to pass data to a client component that couldn't be serialized. The error messages make it obvious.
But, if a developer is used to debugging on the server and the client, it's just more of the same. Also, the caching can cause some confusion at first, but I don't think I need to go over that again.
There are some other things about RSCs that take some time to get used to. At first, I didn't realize that a server component can be a child of a client component. This realization made composing server and client components together much easier. What determines whether a component is a server or client component is where they are imported, not the parent/child relationship. So we can use a client component like <QueryProvider>
in a root layout and components that get passed through as children can still be server components.
Another quirk of App Router is not being able to get the current URL pathname in Layout.tsx
. For example, if we have a navbar server component in a layout, we can't get the current pathname in the layout and pass it to the navbar. So, if we want active link styling in the navbar, we have to make each link a client component to access current URL pathname with usePathname()
. I think this is because of partial rendering. We can do some middleware hacks, but it's not recommend.
Trying to deviate from the recommended approach is what will make it harder to debug. This is when we will dig deep to find a solution, possibly without success.
Also, we cannot access searchParams
in Layout.tsx
for a similar reason. A shared layout is not re-rendered during navigation which could lead to stale params between navigation. But, we do have access to searchParams
in Page.tsx
.
These quirks are in the docs and easy to deal with when we come across them. It helps to learn why RSCs, caching, and routing work this way. Also, these aren't issues developers will struggle with more than once or twice.
What I find especially troublesome is the fact that the dev experience doesn't reflect the prod experience at all. It's known that Next produces an optimized output for npm run build but it's not clear on the other hand that sometimes the dev server is kinda hanging itself on a machine that can handle gaming, video cutting and coding at the same time. The worst part is that it's not clear why this happens but it doesn't just happen to me.
Yes, the default dev server has performance issuess. Especially compared to esbuild/Vite. But, turbopack works well now and it's much a much better experience. To enable turbopack, include this in the dev script: "dev": "next dev --turbo",
I was thinking about this a lot. I'm a huge fan of SSR. But what worth is SSR for interactive apps like e.g. Canva?
I already explained benefits of SSR and RSCs so I won't go into that again. But, for highly interactive apps, SSR is still going to be beneficial and the same is true for RSCs. It's not just about SEO.
Also, we can build highly interactive apps using App Router with mostly client components. React is not getting rid of client components and since Next.js is aligned with React's vision, neither is Next.js. Nothing changed about how these kinds of react components work and Next.js has always provided SSR, so that hasn't changed either.
I remember the times when headless was the way to go. We all used SPAs like React or Angular and loaded data from the server. A pretty good approach with the disadvantage of having a lot of code in the frontend. That however was solved by having the option to lazy load. Back then, we noticed we need immediate server-rendered pages especially for search engines so we rendered the SPAs output as well on the server and hydrated it.
As I mentioned before, server-side rendering is about more than just SEO. Also, with client components we can still put as much code on the client as we like, but now we get the additional benefit of SSR and RSC's. I already went over the downsides of relying solely on client-side rendering (SPAs).
Now, I feel like Next pushes hard on people to use server-side features as far as moving frontend functionality to the backend. I like having the option, but using the server for everything might not be the best advise.
We have the option. Neither React nor Next.js are attempting to force developers to use the server for every aspect of their applications. Rather, they are providing server-side capabilities as an additional tool that can be leveraged when it makes the most sense.
It's important to reiterate that the decision to introduce server components in React was not driven by Next.js or Vercel, contrary to the conspiracy theories circulating on social media. As evidenced by the tweet from Dan Abramov that was referenced earlier in this article, the development of server components is an independent effort within the React team. If anything, the React team is telling Vercel what to do.
Also, as stated, for super-interactive Apps like Spotify, I wouldn't want a frontend page switch to trigger backend at all as my Server Component might've fetched initial data that isn't supposed to be fetched anymore when we're already on frontend (e.g. a user id).
We can control when our RSCs fetch data. They can be static and only update using on-demand revalidation, or RSCs can run dynamically at request time. If we always want fresh data when we switch pages, RSCs should be dynamic.
On the client, the staletime of the router cache is 30 seconds
for dynamic rendering, but there is a proposal to set staletime to 0 seconds
. Until then, we can use router.refresh()
or even use something like react-query
to invalidate in intervals. These options should give us the ability to see fresh data on our client when we navigate.
Also, it depends on what is meant by "I wouldn't want a frontend page switch to trigger backend at all". If a RSC was static then a page switch would not trigger the server component to run. Although, I don't think this is what David meant. It seems like he wanted fresh data, so the RSC should run dynamically. Or, they could just not use RSCs at all.
For super interactive apps, we would mostly use client components and fetch most of the data on the client. However, we can also do what Convex does and use RSC's to preload data for page load and pass it to client components where they resume the session. Then the client component takes it from there and handles all the real-time updates.
- Convex is basically a type safe real-time database, this is what they are doing with RSCs: