Alberto Harka

next.js

react

2024-02-24

How to build a real-time application with React Server Components in Next.js 14

Despite the controversies that plagued their launch, React Server Components came as a net positive for full-stack developers that rely on React as their chosen front-end framework.

By thinning the line between the server and client so much, we can now build fully-featured applications quicker than before, with less complexity to maintain and work around throughout the lifecycle of our products.

Since modern applications are expected to be fast, interactive, and users want to see the latest data as soon as it's available without performing any action on their part, it's very common for real-time features to be requested.

In the context of applications built with Server Components, this means that the server should be able to revalidate the component tree and send the HTML to the page as soon as the new data is available.
But we can't connect Server Components to a WebSocket, and even if we could, that would be useless since the HTTP connection is unidirectional.

Once the HTTP stream is closed, as soon as the server finishes resolving the component tree into HTML, the connection is dead.
So how can we revalidate the UI of an application built with React Server Components in real-time?

The answer is simple... just refresh the page when the new data is available.

Since calling router.refresh() in Next.js is not a full page request but a request for changes in the current page, this is the appropriate way for updating Server Components in real-time.

But putting this into practice is not trivial at all, so the focus of this article will be on how Next.js allows you to build a nice set of abstractions that will make the development of simple real-time features in your application trivial.

The trade off of real-time features in Next.js

The architecture of Next.js does not allow you to include a WebSocket service or similar in your backend, so you will have to rely on a third-party service to handle the real-time notifications.

For some developers, like me, this is great because it allows you to care less about non-business complexity and delegate that effort to somebody else... but it's a trade off that not everybody is willing to do, and for some projects and companies it may be better to avoid delegating it.

The demo

We will store a counter value in a database and will build a page that will show the value of this counter and will update in real-time when a user clicks a button.

Setting up the project

In this example I will use Pusher for real-time notifications and an instance of Redis hosted on Upstash to store the state of the application, they both offer a free hobby tier that is more than enough for this example.
I will use pnpm as package manager, but you can use whichever package manager you prefer.

Terminal
pnpm create next-app

Confirm all the prompts and then enter the project folder. Then we need to add the external dependencies we will need.

Terminal
pnpm add pusher pusher-js ioredis

In order to make the Pusher and Redis clients work we need to define some enviroment variables that will be used to configure them. You can find the values you need in the dashboard of the respective services.
So let's create a .env file in the root of the project and add the following keys.

.env
# Pusher
PUSHER_APP_ID=<your-pusher-app-id>
NEXT_PUBLIC_PUSHER_KEY=<your-pusher-key>
PUSHER_SECRET=<your-pusher-secret>
NEXT_PUBLIC_PUSHER_CLUSTER=<your-pusher-cluster>

# Redis
REDIS_URL=<your-upstash-redis-connection-url>

Now we can start developing so let's run the development server.

Terminal
pnpm dev

Let's add the state for the counter

We need to store a counter value into our Redis instance and increase the value on user interaction so let's make a state object that will expose to our server-side code functions for getting and setting values in our database.

This object will allow you to handle state around your application without thinking about specific implementation details, that could change during the development.
And it will bring some handy auto-completion too.

./src/server/state.ts
import { Redis } from "ioredis";

const client = new Redis(process.env.REDIS_URL!);

async function incrementCounter() {
  await client.incr("counter");
}

async function getCounter() {
  const counter = await client.get("counter");
  if (counter) return parseInt(counter);

  await client.set("counter", 0);
  return 0;
}

export const state = {
  counter: {
    get: getCounter,
    increment: incrementCounter,
  }
};

Let's add a simple event layer

Since we are building a real-time application we need an event layer, so let's build one using Pusher as the underlying implementation.

We are relying on Server Components so, to begin with, we need a dispatch function that will be called in our server actions when we want to notify the client that the page should be refreshed.

./src/modules/events/server.ts
import Pusher from "pusher";

const pusher = new Pusher({
  appId: process.env.PUSHER_APP_ID!,
  key: process.env.NEXT_PUBLIC_PUSHER_KEY!,
  secret: process.env.PUSHER_SECRET!,
  cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
});

export async function dispatch(
  channel: string,
  event: string,
  data: unknown,
) {
  await pusher.trigger(channel, event, data);
}

Now we need a way to react to these events, and since we can't listen for events in Server Components we will create a custom hook called useEventListener that will allow us to execute a callback in our client code once we receive an event.

./src/modules/events/client.ts
"use client";

import { useEffect } from "react";
import Pusher from "pusher-js";

const clientPusher = new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY!, {
  cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
});

export default function useEventListener(
  channelName: string,
  event: string,
  handler: (data: unknown) => void,
) {
  useEffect(() => {
    const channel = clientPusher.subscribe(channelName);
    channel.bind(event, handler);

    return () => {
      channel.unbind(event, handler);
      clientPusher.unsubscribe(channelName);
    };
  });
}

Adding type ergonomics to the event layer

The event layer we built so far works, but it does not scale well since it allows for any string to be passed as channel and event names.
This could lead to a lot of chaos when the number of channels and events increases.

We can rely a bit more on TypeScript to make our life a lot easier here and it will help us to not waste mental energy in remembering channel and event names or payload types by allowing the IDE to auto-complete them.

And since TypeScript won't allow us to put values we have not defined, it will save us a lot of headache by preventing many bugs like typos on channel and event names.

Let's create a Mapping type where we will associate event names and payload types to the channels we want to create in our application.
For now we are dealing only with the counter, and we need to react when the value gets incremented so we will create a counter channel, an increment event and we'll set the payload as null for now since we don't need to send a payload.

./src/modules/events/types.ts
type Mapping = {
  counter: {
    increment: null;
  };
};

export type Channel = keyof Mapping & string;
export type ChannelEvent<T extends Channel> = keyof Mapping[T] & string;
export type ChannelPayload<
  T extends Channel,
  E extends ChannelEvent<T>,
> = Mapping[T][E];

Now it's time to make our dispatch function to accept only the channels we exposed as a string union on the Channel type and force the type of event and the payload to change based on the channel and the event we are sending.

./src/modules/events/server.ts
import Pusher from "pusher";
import { type ChannelPayload, type Channel, type ChannelEvent } from "./types";

const pusher = new Pusher({
  appId: process.env.PUSHER_APP_ID!,
  key: process.env.NEXT_PUBLIC_PUSHER_KEY!,
  secret: process.env.PUSHER_SECRET!,
  cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
});

export async function dispatch<
  TChannel extends Channel,
  TEvent extends ChannelEvent<TChannel>,
>(channel: TChannel, event: TEvent, data: ChannelPayload<TChannel, TEvent>) {
  await pusher.trigger(channel, event, data);
}

Let's apply the same principle to our useEventListener hook.

./src/modules/events/client.ts
"use client";

import { useEffect } from "react";
import Pusher from "pusher-js";
import { type Channel, type ChannelPayload, type ChannelEvent } from "./types";

const clientPusher = new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY!, {
  cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
});

export default function useEventListener<
  TChannel extends Channel,
  TEvent extends ChannelEvent<TChannel>,
>(
  channelName: TChannel,
  event: TEvent,
  handler: (data: ChannelPayload<TChannel, TEvent>) => void,
) {
  useEffect(() => {
    const channel = clientPusher.subscribe(channelName);
    channel.bind(event, handler);

    return () => {
      channel.unbind(event, handler);
      clientPusher.unsubscribe(channelName);
    };
  });
}

Triggering the refresh in Server Components

As we discussed before, we can't listen for events in our Server Components, but what we can do is creating a Client Component that will trigger the refresh when we receive the an event.

We will parametrise the channel and the event so we can reuse the same component everywere and just adjust the parameters for channel and event.

./src/modules/events/react.tsx
"use client";

import useEventListener from "@/modules/events/client";
import { useRouter } from "next/navigation";
import { type Channel, type ChannelEvent } from "./types";

export function RefreshListener<
  TChannel extends Channel,
  TEvent extends ChannelEvent<TChannel>,
>({ channel, event }: { channel: TChannel; event: TEvent }) {
  const router = useRouter();
  useEventListener(channel, event, () => router.refresh());
  return <></>;
}

Building the UI

For the UI we need a <Counter /> Server Component that will get the counter value from our state object and, since this is the value that needs to be updated, we will add our <RefreshListener /> to the component too.
Then we'll make an <IncrementCounter /> Server Component that will contain a button that triggers the server action that will increase the value of the counter and then dispatch the increment event on our event layer.

./src/app/page.tsx
import { RefreshListener } from "@/modules/events/react";
import { state } from "@/server/state";
import { dispatch } from "@/modules/events/server";

export default function Home() {
  return (
    <div className="flex min-h-screen w-full flex-col items-center justify-center">
      <div className="flex w-full flex-1 flex-col gap-2 p-2 sm:w-[384px]">
        <div className="w-full rounded-md border border-green-800 bg-green-900 p-4 shadow-sm active:scale-95 active:shadow-inner"></div>
        <div className="flex w-full flex-grow flex-col justify-center gap-2">
          <Counter />
          <IncrementCounter />
        </div>
      </div>
    </div>
  );
}

async function Counter() {
  const counter = await state.counter.get();
  return (
    <div className="flex w-full flex-col gap-1 rounded-md border border-neutral-800 bg-neutral-950 p-2 text-center shadow-sm ">
      <p className="text-sm font-medium">Counter</p>
      <p className="text-xl font-bold">{counter}</p>
      <RefreshListener channel="counter" event="increment" />
    </div>
  );
}

async function IncrementCounter() {
  const incrementCounterAction = async () => {
    "use server";
    await state.counter.increment();
    await dispatch("counter", "increment", null);
  };

  return (
    <form action={incrementCounterAction}>
      <button
        type="submit"
        className="w-full rounded-md border border-green-800 bg-green-900 p-4 shadow-sm active:scale-95 active:shadow-inner"
      >
        <p className="text-center text-sm font-bold">Counter++</p>
      </button>
    </form>
  );
}

Time to test the result

Now open the page in two different browser windows and start interacting with the increment button.
Every time you click on it you will see the value update in real-time on both windows.

But does it scale well?

Every abstraction can look perfect when you do a single cherry-picked example, so let's try adding some more components that update in real-time.

For example, let's add a time value that will contain a timestamp and will be updated with the timestamp of the moment the setter function will be called.

./src/server/state.ts
async function getTime() {
  return await client.get("time");
}

async function updateTime() {
  const time = new Date().toISOString().split(".")[0].replace("T", " ");
  await client.set("time", time);
}

export const state = {
  [...],
  time: {
    get: getTime,
    update: updateTime,
  },
};

Now we need to extend the Mapping type in our event layer types with a new channel

./src/modules/events/types.ts
type Mapping = {
  counter: {
    increment: null;
  };
  timestamp: {
    update: null;
  };
};

Now it's time for the components. Similarly to what we did for the counter we need a <Time /> component that will show the timestamp and will refresh when the time is updated through an action in an <UpdateTime /> component.

./src/app/page.tsx
[...]
export default function Home() {
  return (
    <div className="flex min-h-screen w-full flex-col items-center justify-center">
      <div className="flex w-full flex-1 flex-col gap-2 p-2 sm:w-[384px]">
        <div className="flex w-full flex-grow flex-col justify-center gap-2">
          <Counter />
          <IncrementCounter />
          <Time />
          <UpdateTime />
        </div>
      </div>
    </div>
  );
}
[...]
async function Time() {
  const time = await state.time.get();
  return (
    <>
      <div className="flex w-full flex-col gap-1 rounded-md border border-neutral-800 bg-neutral-950 p-2 text-center shadow-sm">
        <p className="text-sm font-medium">Time</p>
        <p className="text-xl font-bold">{time ?? "N/A"}</p>
      </div>
      <RefreshListener channel="timestamp" event="update" />
    </>
  );
}

async function UpdateTime() {
  const updateTimestampAction = async () => {
    "use server";
    await state.time.update();
    await dispatch("timestamp", "update", null);
  };

  return (
    <form action={updateTimestampAction}>
      <button
        type="submit"
        className="w-full rounded-md border border-blue-800 bg-blue-900 p-4 shadow-sm active:scale-95 active:shadow-inner"
      >
        <p className="text-center text-sm font-bold">Update Time</p>
      </button>
    </form>
  );
}

And that's it, but I advise you to think about other features and then try to implement them with this pattern so you can feel the benefits of the type-safe event layer yourself.

To sum it up

Considering how easy it was to build real-time features with React Server Components, it's clear to me that they are pushing full-stack development to levels of simplicity unseen before.

Of course, in a real project, there could be real-time features where this solution is not a good fit (for example, a chat window with infinite scroll into the history of messages), where probably an API contract consumed by the client-side code is still the way to go.

But the simplicity of this implementation made it trivial for most of the use cases I have encountered so far.