TL;DR Return { ok, message } from the server action and toast in client code. Pass the action straight to useActionState and toast from useEffect so the form still works without JavaScript. Don't throw for user-facing errors: production shows Minified React error #441. After redirect(), pass the message in a short-lived cookie and reuse its toast id for any loading toast.
A server action runs on the server, so it can't call toast(). The toast has to fire in client code once the action answers. In Next.js 16 with React 19 there are two ways that work: call toast() inside a client function that awaits the action, or keep the server action as the form's action and fire the toast from a useEffect when useActionState hands back new state. Return { ok, message } from the action instead of throwing, because in production a thrown error reaches your toast as "Minified React error #441". If the action ends in redirect(), pass the message to the next page in a short-lived cookie.
I make qodesign, a set of React + TypeScript components with Tailwind (most also use Framer Motion), including a free Toast Stack. Toasts after server actions come up a lot with people building in Cursor, v0 and Lovable, so on October 21, 2026 I built every pattern below in a fresh create-next-app project (Next 16.4.2, React 19.3.0, Sonner 2.0.8, Tailwind v4), ran tsc and next build, started the production server and clicked through each form in headless Chromium. The results here come from that production build. A dev server shows full error messages, which hides the worst problem.
A file marked "use server" only runs on the server, and a toast lives in the browser's JavaScript. Sonner's <Toaster /> is a client component that listens for toast() calls in the same tab. So the action does the work and returns data, and the client turns that data into a toast.
Mount the Toaster once in the root layout, so a toast fired on one page survives a client navigation to the next:
The shortest version wraps the server action in a client function and passes that to useActionState. React's docs allow side effects inside that function, and toast() is one:
tsx
"use client";import { useActionState } from "react";import { toast } from "sonner";import { saveName, type SaveState } from "./actions";export function NameForm() { const [state, formAction, pending] = useActionState(async (prev: SaveState, formData: FormData) => { const next = await saveName(prev, formData); if (next?.ok) toast.success(next.message); else if (next) toast.error(next.message); return next; }, null); return ( <form action={formAction}> <label htmlFor="name">Name</label> <input id="name" name="name" /> <button disabled={pending}>{pending ? "Saving…" : "Save"}</button> <p aria-live="polite">{state?.message}</p> </form> );}
In my test this showed "Name can't be empty" for an empty submit and "Saved Ana" for a valid one. The button read "Saving…" 60 ms after the click. Submitting "Ana" twice in a row gave two toasts.
After a successful submit the input was empty again. React resets every uncontrolled field in a form once its action succeeds, which the form docs state plainly. If you want the value to stay, make the input controlled or put the value back from the returned state.
The catch with this option shows up with JavaScript off. I loaded the page with JavaScript disabled, typed a name and pressed Save. Nothing happened: no message, no navigation. A client function can't run before the bundle loads, so the form has nothing to post to.
With JavaScript on, it behaved the same as option 1: one toast per submit, two toasts for the same name twice. With JavaScript disabled, the form still posted and the page came back showing "Saved Eve" in the aria-live paragraph. React's docs say a server function passed to useActionState lets the server's response show before hydration, and that matched what I saw.
So keep the inline message even if you have toasts. It's the only feedback a user gets before your JavaScript loads, and the aria-live region is what screen readers read out.
Pattern
Toast with JS on
Works with JS off
Extra code
Client wrapper (option 1)
yes
no, the submit does nothing
none
Server action + useEffect (option 2)
yes
yes, inline message only
one effect
The id built from state.at keeps the effect from stacking a duplicate if it ever runs twice for the same result, since Sonner replaces a toast that already has that id.
The free server action toast guide wraps the action in toast.promise, which shows a spinner and then the result. That works for success. For failures, I tested an action that does throw new Error("Name can't be empty"):
In the production build the toast read: "Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings." The request came back as a 500. React's error page for #441 explains it: the specific message is omitted in production builds to avoid leaking sensitive details.
In development you'd see your own message, so this only shows up after you deploy. Throw for bugs you didn't expect, and return { ok: false, message } for anything you want the user to read. If you still like toast.promise, have the action return the result object and throw on the client side when ok is false.
This was the case that broke in the most confusing way. I had a client wrapper that showed a loading toast, awaited an action, and the action called redirect("/done"):
The redirect happened, but the line after await never ran. The "Creating…" toast was still spinning on /done ten seconds later, because nothing ever told it to finish. Next.js's redirect docs say redirect throws and should be called outside any try block, so code after it on the server never runs. My test showed the awaiting client code doesn't resume either.
What worked was a flash cookie. The action sets a short-lived cookie before redirecting, and a small client component on the next page reads it, deletes it and shows the toast:
ts
// app/actions.ts"use server";import { cookies } from "next/headers";import { redirect } from "next/navigation";export async function createProject(formData: FormData) { const name = String(formData.get("name") ?? "").trim() || "Untitled"; // create the project here (await cookies()).set("flash", JSON.stringify({ type: "success", message: `Created ${name}` }), { path: "/", maxAge: 60, httpOnly: false, sameSite: "lax", }); redirect("/done");}
Render <FlashToast /> on the page you redirect to, or once in the layout. In my run, "Created Moon" showed once on /done, the cookie was gone afterwards, and a reload showed no toast. The cookie isn't httpOnly because the browser has to read it. Keep only the message in it, nothing private. Next.js's cookies docs say you can set cookies in server functions, which is why this works from inside the action.
If you want the loading toast too, give it the same id as the flash toast:
The flash toast then replaces the spinner. My test of this version showed only "Created Star" on /done, and it had closed on its own six seconds later.
With JavaScript disabled the client-wrapped redirect form did nothing at all, the same as option 1. If the form has to work before hydration, pass createProject straight to <form action> and drop the loading toast.
For most forms I'd use option 2: server action straight into useActionState, an aria-live message, and a toast from useEffect. It keeps working without JavaScript and needs one effect. Use the client wrapper when the form only exists after hydration anyway, for example inside a dialog. Use the flash cookie whenever the action redirects.
For delete buttons, an undo toast usually beats a confirm dialog. The delete with undo guide shows that pattern with a server action. If you want the delete button itself to need a deliberate press, the Hold to Delete button is a paid piece with full source.
Things I didn't test: other toast libraries (react-hot-toast, shadcn's Sonner wrapper), useOptimistic, and Safari or Firefox. I ran everything in Chromium.
No. The action runs on the server and Sonner's Toaster runs in the browser. Return data from the action and call toast() in client code, either in a client function that awaits the action or in a useEffect that watches useActionState's state.
React hides the message of errors thrown during server rendering and server functions in production builds. In my test a thrown "Name can't be empty" reached toast.promise as "Minified React error #441". Return { ok: false, message } instead of throwing for errors users should read.
Set a short-lived, non-httpOnly cookie with the message in the action, then call redirect(). A client component on the next page reads the cookie in a useEffect, deletes it and calls toast(). Code after an awaited redirecting action never runs, so a loading toast started before it will keep spinning unless it shares the flash toast's id.
Yes, if you pass the server action itself. In my test the form posted with JavaScript disabled and the page showed the returned message. Wrapping the action in a client function breaks this, because that function can't run until the bundle loads.
React resets uncontrolled form fields after a successful form action. Use a controlled input, or set defaultValue from the returned state, if the value should stay.