# Toast Notifications After a Next.js Server Action (Sonner, useActionState, redirect)

> 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.

- URL: https://qodesign.dev/blog/toast-notifications-nextjs-server-actions
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Next.js fixes
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

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](/components/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.

## Why can't a server action show a toast?

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:

```tsx
// app/layout.tsx
import { Toaster } from "sonner";
import "./globals.css";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Toaster richColors position="bottom-right" />
      </body>
    </html>
  );
}
```

The action itself returns a plain object. A timestamp in the state makes every result a new object, even when the message is the same twice:

```ts
// app/actions.ts
"use server";

export type SaveState = { ok: boolean; message: string; at: number } | null;

export async function saveName(_prev: SaveState, formData: FormData): Promise<SaveState> {
  const name = String(formData.get("name") ?? "").trim();
  if (!name) return { ok: false, message: "Name can't be empty", at: Date.now() };
  // write to your database here
  return { ok: true, message: `Saved ${name}`, at: Date.now() };
}
```

## Option 1: call toast() in a client wrapper

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](https://react.dev/reference/react-dom/components/form) 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.

## Option 2: keep the server action and toast from useEffect

Pass the server action itself to `useActionState`, then fire the toast when the state changes:

```tsx
"use client";

import { useActionState, useEffect } from "react";
import { toast } from "sonner";
import { saveName } from "./actions";

export function NameForm() {
  const [state, formAction, pending] = useActionState(saveName, null);

  useEffect(() => {
    if (!state) return;
    if (state.ok) toast.success(state.message, { id: `save-${state.at}` });
    else toast.error(state.message, { id: `save-${state.at}` });
  }, [state]);

  return (
    <form action={formAction}>
      <input name="name" aria-label="Name" />
      <button disabled={pending}>Save</button>
      <p aria-live="polite">{state?.message}</p>
    </form>
  );
}
```

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](https://react.dev/reference/react/useActionState) 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.

## Why does my toast say "Minified React error #441"?

The [free server action toast guide](/react/server-action-toast) 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")`:

```tsx
toast.promise(saveOrThrow(""), {
  loading: "Saving…",
  success: (r) => `Saved ${r.name}`,
  error: (e) => (e instanceof Error ? e.message : "Something went wrong"),
});
```

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](https://react.dev/errors/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.

## How do I show a toast after redirect() in a server action?

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")`:

```tsx
<form
  action={async (formData) => {
    toast.loading("Creating…", { id: "create" });
    await createProject(formData); // calls redirect("/done")
    toast.success("Created", { id: "create" }); // never runs
  }}
>
```

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](https://nextjs.org/docs/app/api-reference/functions/redirect) 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");
}
```

```tsx
// app/FlashToast.tsx
"use client";

import { useEffect } from "react";
import { toast } from "sonner";

export function FlashToast() {
  useEffect(() => {
    const raw = document.cookie.split("; ").find((c) => c.startsWith("flash="));
    if (!raw) return;
    document.cookie = "flash=; Max-Age=0; path=/";
    try {
      const { type, message } = JSON.parse(decodeURIComponent(raw.slice(6)));
      if (type === "error") toast.error(message, { id: "flash" });
      else toast.success(message, { id: "flash" });
    } catch {}
  }, []);
  return null;
}
```

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](https://nextjs.org/docs/app/api-reference/functions/cookies) 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:

```tsx
action={async (formData) => {
  toast.loading("Creating…", { id: "flash" });
  await createProject(formData);
}}
```

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.

## Which pattern should I use?

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](/react/delete-with-undo-server-action) shows that pattern with a server action. If you want the delete button itself to need a deliberate press, the [Hold to Delete button](/components/hold-to-delete) is a paid piece with full source. To build one yourself, with keyboard support and an undo window before the server action runs, read [Hold to Delete Button in React and Tailwind CSS](/blog/hold-to-delete-button-react-tailwind).

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.

## FAQ

### Can I call toast() inside a server action?

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.

### Why does my server action error show as "Minified React error #441" in production?

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.

### How do I show a toast after redirect() in a Next.js server action?

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.

### Does useActionState work without JavaScript?

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.

### Why does my form input clear after the action runs?

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.

## Sources

- [Next.js docs: Forms](https://nextjs.org/docs/app/guides/forms)
- [Next.js docs: redirect](https://nextjs.org/docs/app/api-reference/functions/redirect)
- [Next.js docs: cookies](https://nextjs.org/docs/app/api-reference/functions/cookies)
- [React docs: useActionState](https://react.dev/reference/react/useActionState)
- [React docs: form](https://react.dev/reference/react-dom/components/form)
- [React error #441](https://react.dev/errors/441)
- [Sonner docs: toast()](https://sonner.emilkowal.ski/toast)
