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.
Why not use browser JavaScript to destroy the cookie?
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.