Toast messages in React Server Components

Ryan Toronto

Ryan Toronto

Today we're going to add toast messages to a Next.js application using React Server Components.

This tutorial will use Server Functions, Cookies, useOptimistic, and the excellent Sonner library to create a toast system that can be invoked from any server action like so:

"use server";

export async function save() {
  await toast("Blog post successfully saved!");
}

Step 1: Getting started

We'll start with a brand new Next.js app and create a page that renders a form for writing blog posts.

This form will invoke a server action that saves the post when submitted.

import { saveBlogPostAction } from "./actions";

export default function Page() {
  return (
    <form
      action={saveBlogPostAction}
      className="mt-24 rounded border border-gray-200 bg-white px-4 pb-6 pt-4 shadow"
    >
      <h1 className="text-center text-2xl font-bold tracking-tight text-gray-900">
        Toast messages
      </h1>
      <div className="mt-4">
        <div>
          <label
            htmlFor="title"
            className="text-sm font-semibold tracking-tight text-gray-500"
          >
            Title
          </label>
        </div>

        <div className="mt-1">
          <input
            type="text"
            name="title"
            className="w-full rounded border border-gray-200 px-2 py-1"
          />
        </div>
      </div>

      <div className="mt-4">
        <div>
          <label
            htmlFor="content"
            className="text-sm font-semibold tracking-tight text-gray-500"
          >
            Content
          </label>
        </div>

        <div className="mt-1">
          <textarea
            name="content"
            rows={4}
            className="w-full rounded border border-gray-200 px-2 py-1"
          />
        </div>
      </div>

      <div className="mt-4">
        <button
          type="submit"
          className="inline-flex items-center justify-center rounded bg-black px-3 py-1.5 text-sm font-semibold text-white active:bg-black/60"
        >
          Save
        </button>
      </div>
    </form>
  );
}

Step 2: Creating the Toast

Let's create our first toast message using cookies. We'll add a new function called toast alongside our server action.

"use server";

import { cookies } from "next/headers";

export async function saveBlogPostAction(formData: FormData) {
  // ...
}

async function toast(message: string) { 
  const cookieStore = await cookies(); 
  const id = crypto.randomUUID(); 
  cookieStore.set(`toast-${id}`, message, { 
    path: "/", 
    maxAge: 60 * 60 * 24, // 1 day
  }); 
} 

This function will create a cookie named toast- followed by a random ID. The reason we use a random ID is to ensure that the cookie name is unique. This allows us to create multiple toast messages that don't overwrite each other.

Finally, let's call the toast function in saveBlogPostAction.

export async function saveBlogPostAction(formData: FormData) {
  const data = Object.fromEntries(formData.entries());

  // ... save the blog post ...

  await toast(`Blog post ${data.title} successfully saved!`); 
}

Whenever the post is saved, a new cookie will be created with the "Blog post successfully saved!" message.

Step 3: Displaying the Toast

Now that we have a way to create toast messages, we need to display them.

We'll create a new server component, called <Toaster />, that will find all the toast cookies and display their messages.

import { cookies } from "next/headers";

export async function Toaster() {
  const cookieStore = await cookies();
  const toasts = cookieStore
    .getAll()
    .filter((cookie) => cookie.name.startsWith("toast-") && cookie.value)
    .map((cookie) => ({
      id: cookie.name,
      message: cookie.value,
    }));

  return (
    <div>
      {toasts.map((toast) => (
        <div key={toast.id} className="mt-4">
          <p>{toast.message}</p>
        </div>
      ))}
    </div>
  );
}

This Server Component starts by reading all cookies and then filters them down to only include the ones that start with toast-. It then renders each toast message.

Let's get this component added to the root layout.

import { Toaster } from "./toaster";

export default function RootLayout({
  children,
}: Readonly<{
  children: ReactNode;
}>) {
  return (
    <html lang="en">
      <body className="mx-auto max-w-2xl bg-slate-100 antialiased">
        {children}

        <Suspense>
          <Toaster />
        </Suspense>
      </body>
    </html>
  );
}

The reason we wrap the <Toaster /> component in a <Suspense> boundary is because the cookies() function used inside of Toaster is async. We don't want it to block rendering for the rest of the page.

Another benefit of using a <Suspense> boundary is that if you opt into Next's Partial Prerendering feature then reading cookies will not force your pages to become dynamic.

And now we've got some visual feedback! Every time we save the blog post, a new cookie will be created and the <Toaster /> component will show its message.

Our toast messages are working, but they're not dismissible. We need to add a way to clear toasts.

Step 4: Removing the Toast

To remove the toast, we'll create a new server action that deletes toast cookies. We'll do this inside our Toaster server component.

import { cookies } from "next/headers";

export async function Toaster() {
  const cookieStore = await cookies();
  const toasts = cookieStore
    .getAll()
    .filter((cookie) => cookie.name.startsWith("toast-") && cookie.value)
    .map((cookie) => ({
      id: cookie.name,
      message: cookie.value,
      dismiss: async () => { 
        "use server"; 
        const cookieStore = await cookies(); 
        cookieStore.delete(cookie.name); 
      }, 
    }));

  return (
    <div>
      {toasts.map((toast) => (
        <div key={toast.id} className="mt-4">
          <p>{toast.message}</p>
          <form action={toast.dismiss}>
            <button className="text-red-500">Dismiss</button>
          </form>
        </div>
      ))}
    </div>
  );
}

In the code above, the dismiss server action gets created as we're reading the toast cookies. This allows it to close over the cookie name and delete it whenever the action is invoked.

Step 5: A latency problem

So far we've been using the server to create and destroy toast messages. When developing on localhost this is fast and feels instant, but when deployed to a server there could be latency when dismissing a toast.

To avoid these latency issues, we'll use the useOptimistic hook from React to create a local copy of our toast messages and instantly remove them in the browser when the dismiss button is pressed. In the background, we'll still call the server action that deletes the cookie.

In order to do this, we'll first need to create a client component called <ClientToasts /> that takes toasts as a prop and renders them.

import { cookies } from "next/headers";
import { ClientToasts } from "./client-toasts";

export async function Toaster() {
  const cookieStore = await cookies();
  const toasts = cookieStore
    .getAll()
    .filter((cookie) => cookie.name.startsWith("toast-") && cookie.value)
    .map((cookie) => ({
      id: cookie.name,
      message: cookie.value,
      dismiss: async () => {
        "use server";
        const cookieStore = await cookies();
        cookieStore.delete(cookie.name);
      },
    }));

  return <ClientToasts toasts={toasts} />;
}

Our toast system works just like before, but it's now split between a server and client component. We're now ready to add useOptimistic.

Step 6: Using useOptimistic for instant dismissal

Let's use the useOptimistic hook to create a local copy of all the toasts messages.

"use client";

type Toast = {
  id: string;
  message: string;
  dismiss: () => Promise<void>;
};

export function ClientToasts({ toasts }: { toasts: Toast[] }) {
  const [optimisticToasts, remove] = useOptimistic(toasts, (current, id) =>
    current.filter((toast) => toast.id !== id), 
  ); 

  const localToasts = optimisticToasts.map((toast) => ({  
    ...toast, 
    dismiss: async () => { 
      remove(toast.id); 
      await toast.dismiss(); 
    }, 
  })); 

  return (
    <div>
      {localToasts.map((toast) => ( 
        <div key={toast.id} className="mt-4">
          <p>{toast.message}</p>

          <form action={toast.dismiss}>
            <button className="text-xs text-red-500">Dismiss</button>
          </form>
        </div>
      ))}
    </div>
  );
}

The first thing we do is pass the toasts from the server to the useOptimistic hook. This hook will return a copy of toasts that we can use to render our messages.

The second argument to useOptimistic is a function that takes the current state and the ID of the toast to remove. This function returns a new array of toasts without the one that was dismissed. We'll call this function remove.

const [optimisticToasts, remove] = useOptimistic(toasts, (current, id) =>
  current.filter((toast) => toast.id !== id),
);

Next, we need to make sure that when a toast is dismissed, we remove it from the local optimistic state and call the server action to delete the cookie.

We do this by creating a new localToasts array that maps over the optimisticToasts and adds a new dismiss function to each toast.

const localToasts = optimisticToasts.map((toast) => ({
  ...toast,
  dismiss: async () => {
    remove(toast.id);
    await toast.dismiss();
  },
}));

And that's it! Our client component can loop over and render localToasts. Nothing else about our component needs to change.

You might be asking yourself if it would be easier to use JavaScript in the browser to destroy the cookie. That's certainly doable, and a perfectly valid approach.

There are some benefits to using a server action though. First, you don't have to deal with the Browser's document.cookie API, which is a bit of a pain to work with. Second, browsers are extremely hostile when it comes to storing JavaScript cookies, often deleting them after just a few days. And finally, using server-only cookies means that the cookie data is a little more secure since it is unreadable by JavaScript.

In reality, none of these benefits really apply to our toast system, but I find things to be a little easier if I'm able to avoid writing client-side cookie code.

So by using a server action with useOptimistic we get the best of both worlds: instant feedback on the client and a server code that controls the cookie.

That said, if you feel more inclined to use client-side JavaScript to destroy the cookie, you can do that too.

Step 7: Using Sonner

Our toast messages work, but they certainly don't look good. Luckily for us, the Sonner library exists.

First, install Sonner:

pnpm i sonner

And then we'll update <ClientToasts /> to use Sonner.

"use client";

import { startTransition, useEffect, useOptimistic, useState } from "react";
import { Toaster as SonnerToaster, toast as sonnerToast } from "sonner";

type Toast = {
  id: string;
  message: string;
  dismiss: () => Promise<void>;
};

export function ClientToasts({ toasts }: { toasts: Toast[] }) {
  const [optimisticToasts, remove] = useOptimistic(toasts, (current, id) =>
    current.filter((toast) => toast.id !== id),
  );

  const localToasts = optimisticToasts.map((toast) => ({
    ...toast,
    dismiss: async () => {
      remove(toast.id);
      await toast.dismiss();
    },
  }));

  const [sentToSonner, setSentToSonner] = useState<string[]>([]);

  useEffect(() => {
    localToasts
      .filter((toast) => !sentToSonner.includes(toast.id))
      .forEach((toast) => {
        setSentToSonner((prev) => [...prev, toast.id]);
        sonnerToast(toast.message, {
          id: toast.id,
          onDismiss: () => startTransition(toast.dismiss),
          onAutoClose: () => startTransition(toast.dismiss),
          position: "top-right",
        });
      });
  }, [localToasts, sentToSonner]);

  return <SonnerToaster />;
}

There's a few things needed to make Sonner work.

First, Sonner's API is a little different than what we had before. We need to call the toast function from Sonner (imported as sonnerToast) with the message we want to display. Since we don't want to trigger a side-effect during render, we'll use React's useEffect function to handle that for us.

Second, we need to wrap our calls to toast.dismiss() in startTransition. State updates to optimistic state can only happen inside of a transition.

Finally, we need to render the <SonnerToaster /> component at the end of our <ClientToasts /> component. This component will handle displaying the toast messages for us.

Wrapping up

And that's it! We have a working toast system using Sonner and React Server Components.

One cool thing about this approach is our toast messages work across redirects, new tabs, and page reloads. That's an added benefit of using cookies to store messages.

Here's the code for this tutorial and please reach out to me if you have any questions.

Notes

The reasons I created this tutorial is that I recently added Flash message support to Twofold. Flash messages are a primitive that you can use to build toast messaging into your app.

In the process, I learned a lot about using cookies, server actions, and useOptimistic in React Server Components.

Twofold's Flash message implementation has a few more features that weren't covered in this blog post, like type-safe toast messages, showing toasts in client actions, and the ability to use complex data structures, but the core idea is the same.

Last updated:

Get our latest in your inbox.

Join our newsletter to hear about Sam and Ryan's newest blog posts, code recipes, and videos.