← Default view
Sheet 03Server / Client BoundaryNext.js 16.2.10Rev. 2026-07-13

Firing a toast
across the seam

No toast library ships a server entrypoint. The queue is client memory in all three. A server mutation can only describe a toast and hand it across the boundary — and which channel it hands it through is decided by one question.

Scale1 : 1 code
Sources25 cited
Depthsurvey

Pick your channel by one question: does the action redirect()? No → return {status, message, nonce} and toast in a nonce-guarded effect (or skip useActionState and await the action inside a transition). Yes → redirect() throws NEXT_REDIRECT and unmounts the caller before any return value arrives [3], so set a flash cookie before redirecting and drain it on the next render [9]. Real apps end up running both.

01

The constraint: 'use client' cuts the module graph in two

Elevation

A module carrying the directive — and everything it imports — is bundled and evaluated on the client [6]. All three libraries ship it as the first line of their ESM bundle, verified against the published dist. So <Toaster /> drops into a Server Component layout unchanged — but toast(), the imperative queue-push, is a client reference. A Server Function cannot call it.

SERVER · RSC RENDER + SERVER FUNCTIONS CLIENT BUNDLE · BROWSER MEMORY 'use client' — module graph boundary app/layout.tsx Server Component · stays server-side app/posts/actions.ts 'use server' lib/flash.ts 'server-only' · cookies() sonner/dist/index.mjs 'use client' ← line 1 of the bundle <Toaster /> portal + queue store toast(msg) imperative push · queue lives here, only here PostForm · FlashToaster 'use client' OK — renders it as an element reference toast() is a client reference — a Server Function cannot call it { status, message, nonce } — serializable payload only

Fig. 1 — the boundary. The server may hand across a description of a toast. It may never push one.

Practical reading: "RSC-safe" is table stakes and means less than it sounds. The directive buys you a <Toaster /> in app/layout.tsx without a hand-rolled wrapper. It does not make toast() callable from server code. Every server-origin toast is therefore a serialization problem, not a library feature — which is why the reference implementation is a blog post, not an API [1].

Bill of materialsVer.gzip'use client' in dist<Toaster/> in server layoutServer entrypoint
Sonner⭐ 13k2.0.7 9.4 kB [16] [12] “can be placed anywhere, even in server components such as layout.tsx[11]
react-hot-toast⭐ 11k2.6.0 4.8 kB [17] [13]
react-toastify⭐ 13k11.1.0 9.7 kB [18] [14] v11 auto-injects its stylesheet

Client-boundary cost is the toaster subtree, not the layout: 4.8–9.7 kB gzip, evaluated once, no dependency drag. This is not a reason to pick between them.

⚠ Failure at the joint — and it is not in Sonner

shadcn/ui's generated components/ui/sonner.tsx re-exports the Toaster wrapped in next-themes' useTheme() — and shipped without its own 'use client', producing “Attempted to call useTheme() from the server” [15]. Re-exporting a client component from a directive-less module erases the boundary: the directive is per-module, not viral upward.

02

The junction: does the action redirect()?

Routing diagram
Server Function mutation completes redirect() ? NO CHANNEL A — return { status, message, nonce } useActionState → useEffect guarded by the nonce CHANNEL B — await inside startTransition no effect · no nonce · no StrictMode double-fire YES throws NEXT_REDIRECT · 303 · caller unmounted · return value discarded CHANNEL C — flash cookie, set before redirecting drained on the next render · survives 303, reload, new tab CHANNEL D — redirect('/posts/1?toast=created') re-toasts on refresh, back-nav and shared link — do not detail

Fig. 2 — the critical junction. Everything downstream of it is determined by one if in the action.

Channel A

useActionState + effect

Progressive enhancement survives. The action's return value rides back through the RSC payload.

⚠ Stale re-toast + StrictMode double-fire. Needs a nonce.
Channel B

await in a transition

Skip useActionState entirely. Toast in the same tick the promise resolves.

✓ No hazard. Cost: needs JS, must not redirect.
Channel C

flash cookie

The only channel that survives a 303, a hard reload and a new tab.

⚠ Dynamic-renders the layout. Drain must be a Server Function.
Channel D

?toast= search param

The message lives in the URL. Refresh, back-nav or a shared link re-fires it.

✗ Rejected. Worst of the four.
03

Channel A — the nonce is the whole pattern

Detail · no redirect

useActionState(action, initialState) returns [state, formAction, isPending]; the state is whatever the action returned, redelivered through the RSC payload [5].

app/posts/actions.ts
'use server'
import { revalidatePath } from 'next/cache'

export type ActionState =
  | { status: 'success' | 'error'; message: string; at: number }1
  | null

export async function savePost(
  _prev: ActionState,
  formData: FormData,
): Promise<ActionState> {
  const title = String(formData.get('title') ?? '')
  if (!title) return { status: 'error', message: 'Title is required', at: Date.now() }

  await db.post.create({ data: { title } })
  revalidatePath('/posts')2
  return { status: 'success', message: `Saved “${title}”`, at: Date.now() }
}
app/posts/post-form.tsx
'use client'
import { useActionState, useEffect, useRef } from 'react'
import { toast } from 'sonner'
import { savePost, type ActionState } from './actions'

export function PostForm() {
  const [state, formAction, isPending] = useActionState<ActionState, FormData>(savePost, null)
  const lastToasted = useRef(0)3

  useEffect(() => {
    if (!state || state.at === lastToasted.current) return4
    lastToasted.current = state.at
    const fire = state.status === 'success' ? toast.success : toast.error
    fire(state.message)
  }, [state])

  return (
    <form action={formAction}>
      <input name="title" />
      <button disabled={isPending}>Save</button>
    </form>
  )
}
⚠ Why this bug ships

React deliberately runs effects setup → cleanup → setup in development [23], so a naive useEffect(() => { toast(msg) }) double-toasts in dev — filed against Sonner as issue #322 ⭐ 13k and closed as expected behaviour [10]. Production runs the effect once per mount regardless. That is exactly the trap: it looks like a dev-only annoyance — and the stale re-toast variant is not dev-only at all.

04

Channel B — delete the effect

Detail · no redirect, no progressive enhancement

If you don't need progressive enhancement, the effect is pure ceremony. await the action and toast in the same tick.

app/posts/delete-button.tsx
'use client'
import { useTransition } from 'react'
import { toast } from 'sonner'
import { deletePost } from './actions'

export function DeleteButton({ id }: { id: string }) {
  const [isPending, startTransition] = useTransition()

  return (
    <button
      disabled={isPending}
      onClick={() =>
        startTransition(async () => {
          const res = await deletePost(id) // plain serializable result, never throws to the client
          res.ok ? toast.success('Post deleted') : toast.error(res.message)5
        })
      }
    >
      Delete
    </button>
  )
}

next-safe-action ⭐ 3.0k packages this with typed callbacks, so the toast site is declarative rather than an effect [25]:

const { execute } = useAction(createPost, {
  onSuccess: ({ data }) => toast.success(`Created: ${data.name}`),
  onError:   ({ error }) => toast.error(error.serverError ?? 'Invalid input'),
})

Ref: next-safe-action · useAction [22]

05

Channel C — the flash cookie

Detail · the redirect case

redirect() throws a NEXT_REDIRECT error and terminates rendering of the segment; in a Server Action it serves a 303 [3]. Three consequences, all fatal to Channels A and B:

The workaround the ecosystem settled on — and the one Next's own discussion threads recommend — is to set a cookie before redirecting and read it on the next render [9] ⭐ 141k [8]. Cookies are the only channel that survives a 303, a hard reload, and a new tab [1].

lib/flash.ts — server-only
import 'server-only'
import { cookies } from 'next/headers'

export type Flash = { status: 'success' | 'error'; message: string }

export async function setFlash(flash: Flash) {
  const store = await cookies()
  // random name, not a fixed key: two flashes in one request must not overwrite each other
  store.set(`flash-${crypto.randomUUID()}`, JSON.stringify(flash), {6
    path: '/',
    maxAge: 60,
    httpOnly: true,
    sameSite: 'lax',
  })
}

export async function readFlash() {
  const store = await cookies()
  return store
    .getAll()
    .filter((c) => c.name.startsWith('flash-') && c.value)
    .map((c) => ({ id: c.name, ...(JSON.parse(c.value) as Flash) }))
}
app/posts/actions.ts
'use server'
import { redirect } from 'next/navigation'
import { setFlash } from '@/lib/flash'

export async function createPost(formData: FormData) {
  const post = await db.post.create({ data: { title: String(formData.get('title')) } })
  await setFlash({ status: 'success', message: 'Post created' })
  redirect(`/posts/${post.id}`) // last statement — it throws7
}
app/layout.tsx — stays a Server Component
import { Toaster } from 'sonner'
import { readFlash } from '@/lib/flash'
import { FlashToaster } from './flash-toaster'

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const flashes = await readFlash()8
  return (
    <html lang="en">
      <body>
        {children}
        <Toaster />
        <FlashToaster flashes={flashes} />
      </body>
    </html>
  )
}
app/flash-toaster.tsx
'use client'
import { useEffect, useRef } from 'react'
import { toast } from 'sonner'
import { clearFlash } from './flash-actions' // 'use server'

export function FlashToaster({ flashes }: { flashes: Array<{ id: string } & Flash> }) {
  const fired = useRef(new Set<string>())

  useEffect(() => {
    for (const f of flashes) {
      if (fired.current.has(f.id)) continue
      fired.current.add(f.id)
      ;(f.status === 'success' ? toast.success : toast.error)(f.message)
      void clearFlash(f.id) // must be a Server Function; see below9
    }
  }, [flashes])

  return null
}
Mitigation for callout 8

Push the cookie read into its own <Suspense>-wrapped server component so only that subtree is dynamic — or drop httpOnly and read the flash cookie client-side.

06

Selection schedule

Pick by column, not by taste
Channel Progressive enhancement Survives redirect() Survives reload / new tab Effect needed Main hazard
A · useActionState + effect + nonce Stale re-toast without a nonce [2]
B · await in startTransition None; needs JS [7]
C · flash cookie ✓ (to drain) Dynamic-renders the layout [4]
D · ?toast= param ✓ ⚠ too well Re-toasts on refresh / share [20]

Real apps end up with B for in-place mutations + C for anything that redirects. A is the default the docs nudge you toward — and the one that generates the bug reports. D is listed as a canonical option in the Next.js discussion [8]; the React Router thread flags exactly why not: “URL parameters: refreshes can re-trigger toasts (unreliable)” [20].

Hydration — a note on what is and isn't the risk

The toasters render an empty container during SSR (their queue lives in a client store that is empty on the server), so the toaster itself is not a hydration-mismatch source. The mismatches come from what you put in the toast: server-formatted dates, locale, Date.now() in the message body [24]. Keep flash payloads as opaque strings serialized on the server; format nothing client-side.

07

The part that already exists: React Router 7 session.flash()

Comparative detail

Same tension — the mutation runs on the server, the toast is client state — but the framework has a first-class channel, inherited from Rails: a value written with session.flash(key, value) is deleted the first time it is read [19].

app/routes/posts.new.tsx
export async function action({ request }: Route.ActionArgs) {
  const session = await getSession(request.headers.get('Cookie'))
  const post = await createPost(await request.formData())

  session.flash('toast', { status: 'success', message: 'Post created' })

  return redirect(`/posts/${post.id}`, {
    headers: { 'Set-Cookie': await commitSession(session) },10
  })
}
app/root.tsx
export async function loader({ request }: Route.LoaderArgs) {
  const session = await getSession(request.headers.get('Cookie'))
  const toast = session.get('toast')            // reading consumes it11
  return data({ toast }, { headers: { 'Set-Cookie': await commitSession(session) } })
}

Structurally identical to Channel C — a cookie carrying the message across a redirect — with three differences that matter:

Net: React Router's loader/action model gives you a place for server-origin UI messages. Next's RSC model gives you a return value that a redirect throws away. The Next equivalents are all reconstructions of flash.

08

Source index

25 references
01Build UI — Toast messages in RSCThe reference implementation: uuid-named cookies 02Robin Wieruch — useActionState + toastThe stale re-toast, and the nonce fix 03Next.js docs — redirect()NEXT_REDIRECT · 303 · call outside try/catch 04Next.js docs — cookies().set/.delete only in a Server Function · dynamic rendering 05React docs — useActionState[state, dispatchAction, isPending] 06React docs — 'use client'The module boundary itself 07next.js #67660 ⭐ 141kDuplicate/stale toasts · await the promise directly 08next.js #77389 ⭐ 141kThe three canonical redirect-toast options · no maintainer answer 09next.js #57118 ⭐ 141kReturn-JSON and redirect() are mutually exclusive 10sonner #322 ⭐ 13kStrictMode double-toast · closed as expected 11Sonner — getting started<Toaster/> may live in a server layout.tsx 12sonner@2.0.7 dist'use client' — line 1 13react-hot-toast@2.6.0 dist'use client' — line 1 14react-toastify@11.1.0 dist'use client' — line 1 15shadcn/ui #8173 ⭐ 119kDirective-less re-export erases the boundary 16Bundlephobia — sonner 2.0.734.2 kB min · 9.4 kB gzip · 0 deps 17Bundlephobia — react-hot-toast 2.6.012.2 kB min · 4.8 kB gzip 18Bundlephobia — react-toastify 11.1.034.4 kB min · 9.7 kB gzip 19React Router — sessions & cookiessession.flash() · deleted on first read · nested-loader races 20react-router #11872 ⭐ 57kFlash beats URL params — refreshes re-trigger those 21remix-toast ⭐ 262redirectWithSuccess · getToast → { toast, headers } 22next-safe-action — useActiononSuccess / onError / onSettled callbacks 23React docs — StrictModesetup → cleanup → setup, in development only 24Next.js — hydration errorThe risk is the payload, not the empty toaster 25next-safe-action ⭐ 3.0kType-safe Server Actions with hooks and callbacks
09

Related sheets

Same drawing set
Sheet 03 of the toast drawing set Verified against Next.js 16.2.10 · docs rev. 2026-03-03 Reading time 13 min Canonical page Atlas