How to setup Client-Server Relaying in Next.js

How to setup Client-Server Relaying in Next.js

Get the benefits of server-side rendering and client-side data fetching in a single pattern

Β·

7 min read

Cover photo from Mariam Antadze on Pexels

Displaying fresh data as fast as possible to the user is critical if you want to craft enjoyable websites. And I bet you do. From a company owner standpoint, it also makes the difference between earning a revenue, or not.

See this call-to-action below, displaying the number of customers who preordered the latest fancy product of your e-commerce website. If you have worked in the field of web marketing, you know how impactful these can be.

Screenshot of the preorder form

But components displaying dynamic data, like this customers count, are not easy to implement in Next.js.

In Next, there are two places where you can retrieve data: the server, and the user's browser, aka the client. Picking the right place to fetch data can be difficult. But what if I told you that you don't always have to choose?

This article describes how to implement "client-server relaying", a Next.js architectural pattern that lets you rip off the benefits of both server-side rendering (SEO, reduced TTFB) and client-side rendering (interactivity, real-time updates).

It's not just about e-commerce, Client-Server Relaying is also a valuable pattern for blog upvote systems, chat interfaces, administration dashboards, and other similar use cases.

Prerequisites

This article expects readers to have a minimum familiarity with Next.js and React, or a comparable framework with server and client-side rendering capabilities: Nuxt, Svelte, Qwik, Angular and so on.

First, the client

Let's start with client-side data fetching. If you are a React developer, or using any similar library, you'll think something along these lines:

"Welp, that's the easy part! I know how to fetch data in React from the client. I just need fetch, useState and an effect. I am going to skip to the next section about server-side rendering." - You

We've all been there. But are you sure you are aware of the new React documentation, that has recently been rewritten from scratch?

I'll put the link in the reference section at the bottom, but here are 2 major takeaways:

  • You should probably use a library. Data-fetching is extra annoying to setup because you need to take care about so many little details: loading state, cancellation, caching, cache invalidation (you know, one of the two hard things in computer science), impact on React perfs...

  • If you don't use a library, you need to setup proper cleanup logic. The "useData" hook demonstrated in the documentation shows a proper implementation, written by React core team.

In this article, we will pick the first road and rely on SWR, a data fetching library from Vercel.

Here is how you can get some data in SWR. It's actually done in 2 steps, first we define a generic "fetcher" function that triggers the HTTP request. Then we use this fetcher within the useSWR hook.

/**
 * A SWR fetcher is a function
 * that takes care of the actual HTTP request
 * 
 * Then SWR handles everything else:
 * caching, loading and error states, polling...
 */
async function jsonFetch(url: string) {
  const res = await fetch(url, {
    headers: {
      Accept: "application/json"
    }
  })
  const data = await res.json()
  return data
}

Then comes the hook itself:

export function PreorderCta() {  
    const { data /*, error, isLoading*/ } = useSWR<{ count: number }>(
        '/api/preorder/count',
        jsonFetch)
    return <p>{data.count} users preordered the product</p>
}

The usage is pretty intuitive, you receive your data but also a loading indicator and potentially errors.

It looks pretty simple for now. Let's use a more advanced feature of SWR to immediately understand the benefit of having chosen a library rather than rolling our own logic.

This advanced feature is polling. In SWR, you can use the refreshInterval parameter to tell the hook to send a new request every few seconds. This way, if another customer preorders the product, all other users will automatically get a fresh value without reloading the whole page.

// app/preorder/client-components.tsx
"use client"
export function PreorderCta() {  
    const { data /*, error, isLoading*/ } = useSWR<{ count: number }>(
        '/api/preorder/count',
        jsonFetch,
        {
            // πŸŽ‰ the preorder count is refreshed every 2 seconds
            refreshInterval: 2000        
        })
    return <p>{data.count} users preordered the product</p>
}

You can observe polling in action by opening the network tab of your favorite browser:

Then, the server

The problem with client-side data fetching is that it is subject to cascading. When the user accesses the page, their browser will download the HTML for the page, which in turns references some JavaScript code, which is interpreted, then it finally triggers the request to your API to get the preorder counts.

A few decades later, your app will get the response, and replace the loader with the actual preorder count.

Dull.

The point of server-side rendering, either at build-time (static) or at request-time (dynamic), is to serve an HTML version of the site with data already displayed.

The user will still have to wait a few hundred milliseconds for the page to become interactive with JavaScript, but at least they can already see some interesting data rather than a bunch of ugly loaders.

In Next.js App Router, server-side data fetching is the default way of getting data. The simplest approach is to make the preorder page an asynchronous React Server Component, and fetch the data from there.

It sounds terrifying when written this way, but take a look at the code, it's actually quite short:

// app/preorder/page.tsx
import { cache } from "react"
// I suppose you have a function that can get
// preorders from your database or a 3rd party API
// This is the same function you used to implement the
// "api/preorder/count" API endpoint
import { countPreorders } from "your-db"
// Don't forget to cache for deduplication
// within the React Server Components tree
const rscCountPreorders = cache(countPreorders)

export default async function PreorderPage() {
    const preordersCount = await rscCountPreorders()
    return <PreorderCta initialCount={preordersCount} />
}

Our PreorderCta now receives a preorder count directly from its parent, which is a React Server Component. This value can be displayed before the JavaScript code for this page is even downloaded.

Finally, the relay

We have two preorder counts, one fetched from the server, and one fetched client-side. Both have pros and cons.

The server-side value is immediately available, but can only be updated by refreshing the whole page.

The client-side value can be updated automatically every 2 seconds, but it's only available after the React app becomes interactive, a process named hydration.

Pro-tip to reduce the load on your database for the server count
The preorder page can by dynamic, meaning the preorder count is fetched every time the user refreshes the page. However the most efficient approach is to make it a static page, and use revalidation to update it anytime a customer actually preorders the product. You can also use Partial Prerendering (PPR) to scope the dynamicness to the CTA component rather than the whole page.

The idea of a Client-Server Relay is to use the server-rendered value as the initial displayed value, and then replace it by the client value when it's ready. The schema below illustrates this pattern in Next.js:

Client-server relaying: the client gets initial data from a static, server render, and then the value is updated client-side using JavaScript

Fortunately, SWR has a super intuitive option that lets us reconcile the server and the client worlds and set up Client-Server Relaying: fallback.

// app/preorder/client-components.tsx
"use client"
export function PreorderCta({initialCount}) {  
    const { data /*, error, isLoading*/ } = useSWR<{ count: number }>(
        '/api/preorder/count',
        jsonFetch,
        {
            // πŸŽ‰ the preorder count is refreshed every 2 seconds
            refreshInterval: 2000,
            // πŸŽ‰ the server value 
            // is immediately displayed on the first render
            // it is then updated client-side
            // when JavaScript is ready (after React hydration)
            fallback: { count: initialCount }       
        })
    return <p>{data.count} users preordered the product</p>
}
πŸ’‘
SWR documentation calls this pattern "pre-rendering with default data". I've titled my article "client-server relaying" because, well, it's shorter, running is good for your health, and I was already calling it this way in the early days of SSR with React.

Conclusion: Next.js is all about hybridization

Client-Server Relaying is a pattern that hybridizes client and server data fetching and rendering for maximum performances. But that's not the only hybrid pattern in Next.js. For instance, Partial Prerendering (PPR) is an experimental feature that let's you mix static and dynamic server-side rendering.

Next.js provides so many different approaches to achieve what seems, at first sight, to be the same thing, exactly for this reason: combining different techniques lets you craft super efficient architectures, that enjoy the benefits of each technique without suffering from their limitations.

πŸ’‘
This article is extracted from my Next.js course "Blazing Fast Next.js with React Server Components" published at Newline.co. If you liked "client-server relaying", join the course to discover similar advanced Next.js patterns for maximum performances. The course also demonstrates how to properly handle mutations with Server Actions and revalidation, when a customer submits the form to preorder your product.

Resources

https://swr.vercel.app/docs/revalidation

https://swr.vercel.app/docs/with-nextjs#pre-rendering-with-default-data

https://react.dev/learn/you-might-not-need-an-effect

https://react.dev/reference/react/useEffect#fetching-data-with-effects

Β