Ever wondered about the buzz around Next.js in the React universe? Gabriele explores the evolving landscape of React and the role of Next.js in embracing new full-stack features, discussing its pros and cons while comparing it with other frameworks.
React.js is over 10 years old, and it’s by far the most popular JavaScript front-end framework in the industry. However, despite not being the last kid in the block, React has been evolving quite drastically over the last few years.
At buildo, we’ve been using React since 2015, and we’ve historically developed SPAs with it (using libraries like Create React App (CRA) until it was relevant and - more recently - Vite’s React template). However, we’re always looking to improve, and we decided to start looking more closely at React full-stack frameworks.
Why? Let’s get some context first.
When React became popular, it was advertised as a lightweight solution that focused on rendering a UI conveniently and efficiently using JavaScript. It was famously known as "the V in MVC”, a slogan which highlighted its focus on a very specific part of the frontend stack. This put it in direct contrast with other solutions like Angular.js (now Angular), which offered a more battery-included experience, and would come with things like a router, data fetching, dependency injection, and other opinionated components. In other terms, React was just a library, while Angular was a framework. This means that using React would mean also choosing a set of companion libraries to manage things like state management, routing, data fetching, and so on.
As of today, this isn’t true anymore. Or, better, it’s not strictly true anymore. If we look closely, in the last 2-3 years, React started to:
a) recommending the use of specific frameworks to build apps with it. The React home page says very explicitly, “To build an entire app with React, we recommend a full-stack React framework”
b) including architectural aspects in its design, like data-fetching and caching
So, yes, React is still a library, but it’s now a library that provides a lot of architectural building blocks for frameworks. You can still use React without a framework, but you will not get all of the goodies as intended by the React maintainers (for example, React Server Components require deep framework integration in order to be useful).
This shift was not entirely coincidental. Vercel, the company behind Next.js, one of the most popular React frameworks, always collaborated closely with the React core team at Meta and ended up hiring many prominent team members.
This obviously had a lot of influence on the React and Next design. On the one hand, it made React wade into the territory it had historically stayed away from (like data fetching, full-stack rendering, caching, and so on). On the other hand, it caused Vercel to reimagine how Next would work more integrated with React, which led to Next 13 and the App Directory, which is essentially a rewrite of Next.
The situation today is slightly ambiguous: many new React features make their appearance as Next.js experimental features first, and they get then standardized and documented as core React features. This is the case for things like React Server Components (RSC), Server Actions, the Caching API, the use optimistic hook
, and so on.
To make it even more confusing, React hasn’t had a major version release in almost two years (we’re still on React 18 at the time of writing), and all these features are released in a canary React channel meant for frameworks, although basically, only Next.js uses it. So - essentially - the only way to use any major React feature developed in the last 2 years is through Next.js.
Remix is another popular React full-stack framework, which is also recommended by the React official documentation. Remix is essentially a pioneer in this field: it shaped the way full-stack React frameworks could look like, and it’s now in an awkward position of playing catch-up with the new React API.
In other terms, Remix introduced many features that are now provided by React itself in a slightly different way, and it’s now slowly adopting them. This tweet from Ryan Florence, one of the creators of Remix, sums it up well.
Remix looks very promising, we are keeping our eyes on it, and it’s probably the framework we want to explore next. However, for the scope of our experiment, we decided to take a closer look at Next since we wanted to test the latest and greatest features React had to offer, and Next is currently the only viable option for doing so.
Next.js is not a new framework: its initial release was in 2016, but — we discussed — a lot has changed since then. Next.js now comes in two flavors: the Pages Router and the App Router. In short:
Since our goal is to learn as much as possible about the new React features, we set out on a course to test Next.js using the App Router.
As we saw, Next.js provides a complete framework for React applications, offering new paradigms for everything that was not directly handled by the React library. This includes, for example, routing, data fetching, and server-side rendering.
Like many of the frameworks in the market, Next.js offers a way to generate the app routing logic directly from the files and directories composing the code of your application. In short, the different views of the app are created by just adding new files in a specific hierarchy inside the pages/ or app/ directories. This is a completely different approach than what would be followed with direct use of React, where routing logic must be programmatically expressed through the use of libraries like react-router.
With the release of Next 13, Vercel has also completely revised the routing mechanism by introducing the App Directory. This directory makes more massive use of some features offered by React itself that could not previously exist with the structure provided by the legacy Pages directory, like Suspense, Concurrent Rendering, and Error Boundaries.
In our experience, file-based routing offers a more straightforward approach to structuring your application compared to the direct use of routing libraries like react-router, as it’s based on a simple principle: the filesystem is the main source of truth. Files inside the pages
or app
directory become a route automatically, eliminating the need to manually define routes, which significantly simplifies the setup process. Also, the nested routes approach allows the structure of the application layout in a very easy yet powerful way by defining the common parts of the app layout in a hierarchical structure that’s very easy to understand.
By adding more advanced extra features like Route Groups, Parallel Routes, and Routes Interception, the routing mechanism in Next.js has proven to be extremely powerful, yet it doesn't complicate the management of more basic use cases.
Particularly during the early stages of a project, we frequently found ourselves revising the routing hierarchy due to layouts that could be factored into a common element, new areas of the application that should not conform to the general layout, and the subsequent creation of Route Groups or restructuring of the folder hierarchy. Although from a development standpoint, this process is quite intuitive, it doesn't align well with version control management. Changes to the filesystem structure significantly impact ongoing developments and can create coordination challenges and conflicts across all active branches.
SSR allows rendering React components on the server and sending the fully rendered HTML to the client, where they are just “hydrated” to be interactive. This approach has some benefits in terms of performance (the initial loading time of the page is reduced due to the pre-rendering on the server), and it also - supposedly, it’s never an exact science - benefits SEO since the page content is readily available for indexing and doesn’t require executing JavaScript.
In previous versions of Next.js, SSR was primarily achieved via utilities like getServerSideProps
, a function called at request time (i.e., when the user requests a page), and executed on the server. This function, for example, fetches data at every request and populates the UI with real-time data, similar to how the loader
works in Remix.
With the advent of RSC (React Server Components) in React and the introduction of the App Directory, Next shifted towards an approach that better optimizes the rendering process and enhances the developer experience.
Server Components render exclusively on the server, once per request. They are not hydrated on the client, so they cannot contain interactive bits. In this sense, they are very much like “templates” in other server-oriented frameworks (like PHP, Rails, and so on). The interesting part of the design is that they compose (almost) seamlessly with regular React components, so you can, for instance, have patterns like:
export async function UsersPage() {
const users = await userService.getUsers();
return <UsersList users={users} />
}
where UsersPage
is a server component rendered only once on the server, and UsersList
can be a regular React component.
The composition can go much further, and it’s very powerful. It’s also complemented by Server Actions, a feature that defines functions that run on the server but can be seamlessly called by any component, including the ones that run on the client. You can think of it as an RPC-like API for defining backend endpoints.
We liked the pattern of moving the data-fetching to the server and closely associating it with the rendering logic. This is not unique to RSC, it’s an idea long explored by Remix and previous versions of Next.js as well, but RSC makes the pattern frictionless. Here’s a few comparisons.
In a regular SPA application we may have used something like React Query like this (the data fetching happens on the browser in this example, so getUsers()
would need to be an authenticated endpoint of some sort):
export function UsersPage() {
const { data: users } = useQuery({ queryKey: ["users"]; queryFn: () => userService.getUsers(); });
return <UsersList users={users} />
}
In Next.js with Pages Router it would look like:
export async function getServerSideProps() {
const users = await userService.getUsers();
return { props: { users } };
}
export function UsersPage({ users }: InferGetServerSidePropsType<typeof getServerSideProps>) {
return <UsersList users={users} />
}
In Remix it would look like:
export async function loader() {
const users = await userService.getUsers();
return json({ users });
}
export function UsersPage() {
const { users } = useLoaderData<typeof loader>();
return <UsersList users={users} />
}
Finally, in Next.js with App Router it looks like:
export async function UsersPage() {
const users = await userService.getUsers();
return <UsersList users={users} />
}
It may not seem like much, but co-locating the data-fetching and rendering logic reduces friction and —most importantly— enables composition that was previously impossible on the server. In fact, while all other server solutions were limited to data fetching in top-level routes, Server Components do not have the same limitation.
This means we can have —for example— a StripeCheckOut
component that encapsulates both the UI and the server logic necessary to perform a check-out on Stripe, which we can just drop in our rendering tree.
One interesting advantage of server-side data fetching, in general, is being able to use the “frontend” application as its own BFF (Backend for Frontend). BFFs are traditionally used to aggregate data from multiple sources and provide a nicer API for the front end to work with. With server-side data fetching, we can do the same thing, but directly in the front-end application. Some may frown upon conflating these two “concerns” into a single architectural component, but we would argue that aggregating and consuming data for a front-end are ultimately the same concern.
Composition patterns using React Server Components feel magical — something a bit too much. Since the technical implementation behind them is very sophisticated and very young, the developer experience is not great when it comes to debugging. For example, it’s very hard to inspect an RSC request since it uses custom Content, which the regular browser inspector struggles to understand.
Another con is that while the composition is well thought out, you still need to be very aware of whether you’re in a server component or not, as the abstraction is sometimes quite leaky. A few examples:
This makes refactoring code a bit tedious when you decide to switch from a Server to a Client component, which can happen quite frequently (for example, if you want to add some client-side state or some interactive behavior).
In Buildo, we have years of experience developing React apps in the traditional SPA way.
Bottom line, we liked the general idea of this full-stack direction React is leaning towards. We also see the merit of Next.js in trying to put everything together in a good usable framework. That said, there are a few areas in Next.js specifically we found very lacking, for which we will probably wait a bit longer and explore alternatives in the meantime.
While Next.js is ultimately a way of writing a client-side web application that is powered by a dedicated server, the framework does not give you access to the underlying API.
This means some basic things you would expect to be able to do are basically impossible, for example:
headers()
, cookies()
, and so on).At some point, we wanted to write some automated tests involving RSCs and found the landscape is quite desolate. Popular testing tools like Jest and React testing library still haven’t figured out a way to approach it. There are hacks and workarounds around it, but it feels like a big chunk of the ecosystem has just been left behind and has not caught up yet.
Next.js automatically patches fetch
to introduce its own caching semantics. This is as bad as it sounds, and it seems it will eventually be phased out in favor of more explicit APIs.
The worst part is that fetch
has been patched to cache everything forever by default, which is a terrible idea for a very simple reason: under-caching is a potential performance issue, and over-caching is a bug.
Next.js works under a closed-world assumption where - by default - data can only change in reaction to actions within the app. In practical scenarios this is rarely the case.
For example:
export async function ItemsPage() {
const items = await getItems(); // getItems uses fetch under the hood
return <ItemList items={items} />
}
What happens if those items change in the external system? Congrats, you’ve just introduced a bug since Next.js will cache the items forever on the server, no matter how hard you smash the refresh button in the browser.
How to fix it? You have to pray that whatever data-fetching function you are using allows you to pass options to fetch
so you can pass { cache: "no-store" }
to it. If it uses fetch
underneath but doesn’t allow you to customize it, you then have to either:
export const dynamic = force-dynamic
unstable_noStore()
before making the callFrustratingly, this default applies to POST
and PUT
calls, which is even more counterintuitive. At the same time, it cannot be changed globally, so it’s basically a bug waiting to happen every time you interact with an external system via HTTP.
We knew the Next.js dev server was an area of concern, but experiencing it first-hand is another level. In a relatively small application, we got to a point where navigating to a new page for the first time would take over 30 seconds. There are ways of coping and various workarounds suggested by the Vercel team, but those will - at best - lower the times to a few seconds. When comparing this experience with other modern tools like Vite, working with the Next dev server feels like we’re back in 2018.
Vercel is actively working to improve this with a new bundler, Turbopack, which they claim will have drastically better performance. However, it was not ready for production at the time of writing (we tried it on one of our projects, and it wouldn’t compile, so we dropped the experiment). The readiness of Turbopack can be tracked here: https://areweturboyet.com/.
React is expanding to include primitives and patterns to create rich full-stack applications, and this is very exciting. While it all feels still very premature and not entirely polished, we can already appreciate some of the benefits this entails. In this landscape, Next.js with App Directory is the first framework to directly integrate all the new full-stack React features. While it’s pleasant to work with when it works, it still feels very immature: APIs are unstable, the DX is lacking, and the framework makes it hard to work around it.
That said, we’re optimistic about React's future and liked many of the general architectural choices. It’s great that React is increasingly supporting full-stack architectures while also improving its SPA support.
We’ll keep investing and exploring this area and report back!
Gabriele is co-founder of Buildo, where he works as co-CTO. He is passionate about front-end and back-end architectures, and has long-term experience with React, TypeScript and Scala.
Vincenzo joined Buildo as a Fullstack engineer in 2016, becoming the Frontend tech lead of the company. Passionate about frontend and backend technologies and music.
Are you searching for a reliable partner to develop your tailor-made software solution? We'd love to chat with you and learn more about your project.