adesso Blog

When choosing a framework for a new React project, Next.js is a top choice and has been for several years. When compared to rival products, it offers high web metric performance by focusing on server-side rendering (SSR). Its main competitor at the start, Gatsby, is built around static site generation (SSG) instead. The new kid on the block, Remix, offers similar SSR benefits to Next.js but still has some catching up to do in terms of industry adoption.

The need for server-side rendering

But why do you even need a framework at all? Out of the box, what React does well is client-side rendered (CSR) and single-page applications (SPA). This offers great web experiences by allowing your users to instantly navigate between pages without waiting for them to be fetched from the server.

The major downside of CSR is that the first page your users visit gets shipped in a JavaScript bundle to their browser before being rendered as HTML. This negatively affects important web metrics like First Contentful Paint (FCP) and Total Blocking Time (TBT).

Both metrics (FCP and TBT) benefit massively from SSR and SSG. SSR starts on the server after the first request comes in. It renders your React pages into HTML on the server before shipping them to the browser. This is especially beneficial for dynamic public pages (e.g., weather information) where the HTML output can be cached and served to different users, saving rendering time on subsequent requests. SSG renders static pages before your site gets deployed, removing the rendering time from the request-response lifecycle entirely. This can result in a better time to first byte when compared to SSR.

Note that statically generated sites can still offer dynamic content and interactions by making JavaScript requests from the browser after loading the initial HTML. For interactive dynamic pages, this combination could result in a worse first input delay when compared to SSR.

Next.js’ big competitive advantage comes from its ability to offer a highly efficient hybrid SSR/SSG approach with a very intuitive interface. It simplifies routeing by wrapping React Router inside a file-based routing system while the rest of the React interface remains intact, offering the developer full flexibility.

The trade-offs

With Next.js being the current clear winner in terms of market popularity (measured by StateOfJS’ ‘usage’ metric), here are some things to consider when comparing it to other alternatives.

Vendor lock-in

Next.js has been heavily criticised for being hard to deploy anywhere other than Vercel, a batteries-included Next.js hosting solution owned by Vercel, the company that maintains Next.js. Whilst Next.js can currently be configured to work with other hosting platforms (notably Netlify and AWS), staying up to date with the latest features may require deploying with Vercel, leaving you at the mercy of their pricing.

React Server Components

In May 2023, Next.js released version 13.4, which now supports the new App Router as the preferred file-based routeing system. This introduces the experimental 'use client' syntax to support React Server Components. Since these features are currently considered experimental by React, several libraries in the React ecosystem still lack full support for them (at the time of writing). One such example is react-testing-library, which is currently unable to render asynchronous server components.

The App Router

While the App Router may look fundamentally different from the page router due to naming changes in the file-based routeing system, the main difference we’ll be looking at is server components.

Server vs. client components

In the App Router, every React component is treated as a server component by default, unless explicitly tagged as a client component via 'use client'. Some components, such as page routes, are required to be server components whilst others can choose to be either server or client components.

Before going into more detail, it’s important to understand where the boundary lies between server and client components. Remember that, when used out of the box, React renders HTML on the client using CSR. SSR frameworks like Next.js perform that same rendering step on the server instead. This SSR functionality applies to both server and client components. The difference is that client components get (partially) hydrated on the client whereas server components don’t ship any JS to the client at all. The following table illustrates some of the major differences between server and client components (see also When to use Server and Client Components?):

Library support

Existing, mature libraries in the React ecosystem are often built with client components in mind and therefore lack basic support for server components.

Client-side functionality

Trying to use libraries with client-only logic in server components (any custom components in the App Router are considered server components by default) will result in hard-to-debug errors on your Next.js server. Here’s an example from react-hook-form’s quickstart guide:

Our form component looks like this:

	
		import { useForm } from "react-hook-form";
		export default function Home() {
		  const {
		    register,
		    handleSubmit,
		    formState: { errors },
		  } = useForm();
		  return (
		    <form onSubmit={handleSubmit((data) => console.log(data))}>
		    ...
	

Trying to render this component results in the following runtime error:

	
		 ⨯ src/app/page.tsx (8:13) @ useForm
		 ⨯ TypeError: (0 , react_hook_form__WEBPACK_IMPORTED_MODULE_1__.useForm) is not a function
		    at Home (./src/app/page.tsx:11:119)
		    at stringify (<anonymous>)
		digest: "503401641"
	

Adding a 'use client' directive to this component file transforms it into a client component and resolves the problem. We can mimic that react-hook-form supports server component architecture by creating a wrapper for it like so:

	
		// src/app/react-hook-form.ts
		"use client";
		export * from "react-hook-form";
		// src/app/page.tsx
		import { useForm } from "./react-hook-form";
		// ...
	

This doesn’t fix the problem but it does give us a much better error message:

	
		Error: Attempted to call useForm() from the server but useForm is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.
		    at Home (./src/app/page.tsx:12:120)
		    at stringify (<anonymous>)
		digest: "1355241539"
	

Having this behaviour out of the box would require all library maintainers to explicitly add 'use client' to their client components and hooks just to support this Next.js architecture. Seven months after the stable release of the App Router, popular libraries like react-hook-form are still lagging behind, perhaps signalling a lack of buy-in from the React open-source community to the server component architecture of the App Router.

Testing

react-testing-library (RTL)’s render function currently cannot render asynchronous server components. Existing workarounds allow us to render such components in isolation by stepping out of JSX syntax, but these basic workarounds cannot handle async components nested inside a component tree. Stepping out of JSX syntax also makes RTL’s built-in functions (e.g., wrapper) much harder to use.

The server-client boundary

Server components can only pass serialisable data to client components. This makes patterns like dependency injection harder to follow due to the inability to pass functions over the server-client boundary.

Wrapper components

One early pattern we’ve seen emerge in our own codebase is the use of wrapper components over custom hooks. In traditional client-first React applications, these two options have involved a similar number of trade-offs. Here are some pros and cons of each:

1. Wrapper components:
  • enforce an explicit coupling between data fetching and rendering
	
		 	import { ProductTeasers } from "./product-teasers";
		function ProductTeasersWrapper() {
		  const products = getProducts();
		  return <ProductTeasers products={products} />;
		}
	
  • make components harder to name due to overlap
2. Custom hooks:
  • create an implicit connection between data fetching and rendering:
	
		 	// src/app/use-products.js
		function useProducts() {
		  const products = getProducts();
		  return products;
		}
		 	// src/app/products/page.jsx
		function ProductsPage() {
		  // Do we use this hook in the consumer...?
		  const products = useProducts();
		  return <ProductTeasers products={products} />;
		}
		 	// src/app/products/teasers.jsx
		function ProductTeasers() {
		  // ...or inside the component itself?
		  const products = useProducts();
		  return products.map((product) => <ProductTeaser product={product} />);
		}
	
  • extract logic without cluttering the component tree

In App Router, the wrapper component pattern becomes very intuitive since we want to perform:

  • 1. Data fetching on the server
  • 2. Rendering on the client

Naming is still clumsy here but we can create explicit coupling while providing 'use client' where needed. It ends up looking something like this:

	
		// src/app/products/teasers.tsx
		// This is our server-side wrapper component.
		// It gets a concise name because it's what we want to expose to its consumers.
		async function ProductTeasers() {
		  const products = await fetchProducts();
		  return <ProductTeasersForClient products={products} />;
		}
		// src/app/products/teasers.client.jsx
		"use client";
		// Our inner client component gets a longer name to avoid overlap
		function ProductTeasersForClient({ products }) {
		  return products.map((product) => <ProductTeaser product={product} />);
		}
	

Writing custom hooks in the App Router feels clumsy since they just end up being regular functions (i.e., they don’t use React hooks) if written for server components. We’ve found wrapper components to be a more suitable pattern for our use cases so far.

Summary

React offers client-side rendering (CSR) out of the box. Server-side rendering (SSR) and static site generation (SSG) frameworks like Next.js are essential if you want your React application to score well on web metrics. Next.js has faced criticism for being hard to deploy on platforms other than Vercel. Its premature adoption of React Server Components has resulted in a lack of supporting features from some of React’s most mature utility libraries. The architecture of the App Router introduces some fundamental changes which make it difficult for the open-source community to keep up with tooling support.

Closing words

Choosing between Next.js routers is currently very difficult. Building new applications with the old page router feels silly since the transformation is already well underway. Meanwhile, the new App Router doesn’t feel ready to take its place as the preferred way to write state-of-the-art React applications. Is this the beginning of the end for Next.js? Or are they paving the way towards a new era for server-side rendering in React? Only time can tell.

Would you like to find out more about exciting topics from the world of adesso? Then take a look at our previous blog posts.

Picture Bjarki Sigurðsson

Author Bjarki Sigurðsson

Bjarki Sigurðsson has worked as a full-stack developer for over five years and joined adesso as a senior software engineer in September 2023. He specializes in React applications and Next.js has been his framework of choice since 2019.


Our blog posts at a glance

Our tech blog invites you to dive deep into the exciting dimensions of technology. Here we offer you insights not only into our vision and expertise, but also into the latest trends, developments and ideas shaping the tech world.

Our blog is your platform for inspiring stories, informative articles and practical insights. Whether you are a tech lover, an entrepreneur looking for innovative solutions or just curious - we have something for everyone.

To the blog posts

Save this page. Remove this page.