NextJS 13’s App Router, React Server Components, and Confusion

At Theodo, we’re big fans of using the appropriate meta-frameworks to unlock the full potential of a technology. These meta-frameworks typically build upon another framework and help abstract away complexity, allowing us to spend the most amount of time developing valuable features!
We are particularly fond of React Native (usually with Expo) and NextJS. In fact, we have been using both for several years as our go-to solutions for mobile and server-side rendering (SSR) for the web respectively.
After the release of NextJS 13, which comes with some really cool features, I’ve heard some concerns from colleagues on the direction that Vercel is taking the framework. In this article, I will explore one specific reason for the unease.

“Meta-Frameworks”

I have worked on a project that implemented server-side rendered React without NextJS, and although it was an impressive setup, it simply does not seem as elegant.
The job of the “meta-framework” is to abstract away the complexity of some property of the system - in the case of both NextJS and React Native, there are two properties of my code that I mostly don’t need to keep in mind:
  1. where code is being executed (i.e. server vs client/ Android vs iOS), and
  1. when code is being executed (i.e. build time vs run time).
If you find yourself needing to consider these two things daily during development, it indicates a poorly factored application. This can result in complex interactions within the system (coupling to the runtime context and temporal coupling), which inevitably lead to bugs!
With React Native, I don’t need to consider where my code is executed - I just write “React code” and use the React Native APIs which handle platform-specific interactions. I also don’t need to consider when my code executes - it’s always at run time after it has been built and distributed to the user’s device. Obviously, this abstraction isn’t perfect (if you’ve ever had to use Platform.OS, the illusion has also been broken for you in the past).
It’s important to note that we’re not restricted from breaking this abstraction. With Expo, we can create a config plugin which allows us to hook into the Native code using a structured API.
So, how does NextJS <13 stack up when making these abstractions?

Abstracted Complexity with Next <13

With NextJS <13, writing server-side React was a breeze - the simplest NextJS app was very similar to writing plain React with a file-based router. You didn’t need to consider where the code was being executed - your components render once on the server, send down some pretty, pre-rendered HTML for the user to look at, and then client-side JavaScript takes over for a fully interactive app (hydration).
If you’re doing basic SSR, and you already know React, there aren’t that many extra footguns to worry about introducing in everyday development, and the complexity of hydration is handled by Next. It felt like a low-risk tradeoff to adopt Next if you’re looking for SSR.
It’s important to note, that we’re not restricted to the default rendering pattern; for example, if I want to generate and serve some of my pages statically, I can export getStaticProps instead of getServerSideProps to inject props at build time, rather than run time.
As more complex rendering features are used (SSG, ISR), the abstraction that was hiding our SSR usually becomes less clear and reveals some complexity (which is usually limited to the page level only).
Opting in to SSG requires you to consider when your code is executed. For example, you cannot know every combination of query strings that can be passed in at when you page is being rendered at build time, therefore useRouter returns undefined for router.query when we run this on the server (i.e. initial render when building the pages - here for more). Also, the runtime on the server is not the same as on the client, so be careful using things like Buffer.
These small details can lead to tricky bugs where complexity leaks through the abstraction where you need to consider this in your application code.
For the most part, however, I don’t hear the kind of complaints I have heard with Next 13 - so what changed with this release?

Next 13 - What Changed?

NextJS 13 introduces the “app router”, which uses React Server Components (RSCs) by default - moving NextJS closer to the cannon “React way of doing SSR” since React 18 introduced RSCs.
Vercel implemented an incremental adoption strategy for their new app router, which I really like - it means I can mix the old way and the new way (in theory) and I’m not blocked from upgrading to the latest Next version.
What does this look like in practice you might ask?
 
Well, this:
export default Page() { return <div /> } export const getServerSideProps = async () => { const data = await fetch(); }
Becomes:
export default async Page() { const data = await fetch(); return <div /> }
Gone is the old getServerSideProps API only known to users of NextJS!
 
OK, but really, what has changed versus Next 12?
RSCs only run on the server!
They run server-side - as they used to - however the JavaScript used to generate the static HTML is guaranteed never to be sent to the client. It means, in theory, you can be fetching data, using API keys, and accessing your database right from the React component (which makes some of my peers very uncomfortable).
Let’s review:
Next 12: the user makes a request, React generates HTML on the server, sends back HTML, and runs the React code on the client, which then takes over.
Next 13: the user makes a request, React generates HTML on the server, sends back HTML, and runs some but not all of the React code on the client, which then takes over.
The fact that only some of the JavaScript is sent to the client introduces complexity - with the distinction between RSC and “non-RSC”, I must now keep in mind where code is executed (i.e. server-only or both server and client). As with Next 12, if we’re also using SSG, I need to keep in mind when my code is running (build time vs run time).
For example, I can’t import and pass props into RSC from a non-RSC (think for a second - it doesn’t make sense) and RSCs can’t use useState or most interactivity; for that you will need to use a “non-RSC” component, or as Vercel call them, “client” components.
OK, so we sometimes had to consider when and where our code was running in Next 12 - what changed? I believe two factors impact the ease of adoption:
  1. RSCs are the default mode. They are opt-out of complexity (with "use client"), rather than opt-in to an extra feature. Now by default (using RSCs), I always need to consider where code is being executed whilst developing low-level features.
  1. RSCs introduce complexity across multiple levels of the render tree, whereas complexity in Next 12 is usually encapsulated at the page level, where we usually want to make decisions about rendering/ caching methods. Vercel promotes using RSCs as the branches of the render tree, with leaves of interactivity (minimising the amount of JS sent to the client). It’s an advanced pattern that can be introduced when you have an actual use case.

Thoughts on “use client” vs “server-only”

Here is one of the most common misconceptions I have heard surrounding Next 13: client components don’t render on the server. OK, they’re never advertised as such, but it’s easy to forget that client components also run on the server!
Rephrasing that, "use client" is how NextJS 12 worked.
Vercel have quietly flipped the status quo - if I want my Next 13 app to do the same as Next 12, I need to add "use client" everywhere.
Unlike version 12 (or Expo with config plugins), Next 13 does not start with a crisp abstraction of complexity which you can gradually break down as you need to opt-in to more complex features. Instead, it throws you in deep-end-first, expecting to build on an existing understanding of the various rendering/ data fetching strategies. It expects you to use RSCs and understand how not to leak your API keys to the client.
This is manageable for developers with prior NextJS experience but can be intimidating for beginners.
Perhaps Vercel might have flipped the default and instead of using the "use client" directive, opted for a "server-only" directive, whereby unwitting new developers can write code without caring when or where it runs at first until they absolutely need to opt-in (intentionally) to that complexity.

Concluding Thoughts

Despite my thoughts on its implementation and the reaction of some of my peers, I’m still excited by the possibilities that RSCs have brought to NextJS! I also like that Vercel are implementing RSCs to align with React (the framework it builds upon).
It’s interesting to see how React Native/ Expo aim to keep complexity abstracted away for as long as possible, only exposing it as needed. Having complexity as the default mode of your meta-framework can undermine the trust that developers have in it.