50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
reactMay 21, 2026·8 min read

Toast Notifications Done Right: Sonner, React Hot Toast, and When to Use Each

Most apps get toast notifications wrong — too many, too loud, blocking clicks. Here's how to do them properly with Sonner and when to consider alternatives.

Robert Seghedi

Robert Seghedi

Co-founder, peal.dev

Toast Notifications Done Right: Sonner, React Hot Toast, and When to Use Each

Toast notifications are one of those UI patterns that look simple until you've shipped them in production. Then you discover: they stack weirdly on mobile, they block the save button the user needs to click, they disappear before the user reads them, or — the classic — you end up with seventeen of them on screen because someone clicked a button too fast. We've been through all of it.

The good news is the React ecosystem has mostly solved this. The bad news is there are still a dozen ways to get it wrong even with a good library. This post covers what we've learned using Sonner as our primary choice, when React Hot Toast still makes sense, and the few things no library can save you from.

Why Sonner Is Our Default

Sonner is built by Emil Kowalski, the same person who's responsible for a lot of the UI polish at Vercel. That lineage shows. Out of the box it looks good, animates well, and has sensible defaults for stacking behavior. More importantly, it handles the weird edge cases you don't think about until 2am — like what happens when you fire 10 toasts in rapid succession, or when a toast needs to show a loading state that resolves to success or error.

Setup is two lines. Add the Toaster component once, call toast() anywhere. No context providers, no wrapping your entire app in something, no reading the docs for 20 minutes.

// app/layout.tsx
import { Toaster } from 'sonner'

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

Then anywhere in your app, server actions included (via client callbacks), you just call it:

import { toast } from 'sonner'

// Basic variants
toast('Profile saved')
toast.success('Payment processed')
toast.error('Something went wrong')
toast.warning('Your session expires in 5 minutes')

// Promise-based — this one is genuinely great
toast.promise(saveProfile(data), {
  loading: 'Saving...',
  success: 'Profile saved',
  error: 'Failed to save profile',
})

// With action button
toast('File deleted', {
  action: {
    label: 'Undo',
    onClick: () => restoreFile(fileId),
  },
  duration: 5000,
})

The promise variant is the one we use most. You pass it a promise, it handles the loading/success/error states automatically, and it updates the same toast in place instead of firing three separate ones. Clean.

Integrating Sonner with Server Actions

This is where things get slightly tricky in Next.js App Router. Server Actions run on the server, so you can't call toast() directly from them. The pattern we use is to handle the action result client-side and fire the toast there.

// actions/profile.ts (server action)
'use server'

export async function updateProfile(formData: FormData) {
  try {
    await db.user.update({
      where: { id: getCurrentUserId() },
      data: { name: formData.get('name') as string },
    })
    return { success: true }
  } catch (error) {
    return { success: false, error: 'Failed to update profile' }
  }
}

// components/profile-form.tsx (client component)
'use client'

import { toast } from 'sonner'
import { updateProfile } from '@/actions/profile'

export function ProfileForm() {
  async function handleSubmit(formData: FormData) {
    const result = await updateProfile(formData)

    if (result.success) {
      toast.success('Profile updated')
    } else {
      toast.error(result.error ?? 'Something went wrong')
    }
  }

  return (
    <form action={handleSubmit}>
      <input name="name" />
      <button type="submit">Save</button>
    </form>
  )
}

If you want the loading state too, wrap it in toast.promise() with a regular async function call rather than using the form action directly. It's a small architecture decision but it makes the UX noticeably better — users see immediate feedback instead of wondering if their click registered.

When React Hot Toast Still Makes Sense

React Hot Toast predates Sonner and is more battle-tested in sheer number of production deployments. It has a slightly different API that some people find more intuitive, and it's more customizable at the component level — you can render completely arbitrary JSX as a toast, including interactive elements.

import toast, { Toaster } from 'react-hot-toast'

// Custom component toast
toast.custom((t) => (
  <div
    className={`flex items-center gap-3 rounded-lg bg-white p-4 shadow-lg ${
      t.visible ? 'animate-enter' : 'animate-leave'
    }`}
  >
    <Avatar src={user.avatar} />
    <div>
      <p className="font-medium">{user.name} sent you a message</p>
      <p className="text-sm text-gray-500">Click to view</p>
    </div>
    <button onClick={() => toast.dismiss(t.id)}>×</button>
  </div>
))

// Update existing toast (useful for multi-step flows)
const id = toast.loading('Uploading...')
// later...
toast.success('Upload complete', { id })
toast.error('Upload failed', { id })

The update-by-id pattern is also available in Sonner, but React Hot Toast's API for it feels a bit more explicit. If you're building something where toasts need heavy custom styling that doesn't fit Sonner's design system, or you need truly custom JSX inside each toast, Hot Toast gives you more room to work.

That said, for the 90% case — form feedback, API errors, success confirmations — Sonner's defaults are better and you'll write less CSS.

The UX Rules That Actually Matter

No library fixes bad toast UX. These are the patterns we've baked into our own work after enough user complaints:

  • Success toasts should be short. 2-3 seconds is fine. The action worked, the user knows, move on. Don't make them wait 8 seconds for it to disappear.
  • Error toasts should be longer. 5-6 seconds minimum. The user needs to read what went wrong, possibly while also trying to figure out what to do next.
  • Bottom-right is the safest position on desktop. Top-center is fine for mobile. Never use bottom-center — it overlaps with mobile browser UI on iOS.
  • Don't show success AND error for the same action. Pick one. If the action failed, show the error. If it succeeded, show success. Not both.
  • Deduplicate. If someone clicks submit 5 times, show one toast, not five. Sonner handles this somewhat, but you still need to debounce the action itself.
  • Don't put critical information only in a toast. If an error means the user needs to do something, put it inline near the form too. Toasts disappear.
Toasts are acknowledgments, not error messages. If the user needs to act on something, put it somewhere permanent. Toasts are for 'got it, moving on' moments.

Accessibility: The Part Everyone Skips

Both Sonner and React Hot Toast handle aria-live regions out of the box, which means screen readers announce toasts without you doing anything extra. But there are still things to get right manually.

First: don't put interactive content in auto-dismissing toasts unless you also set a long duration or make them dismissable. If someone's navigating by keyboard and a toast appears with an 'Undo' button, they need enough time to actually reach it. We set duration to 8000 for any toast with an action button.

// Toast with action — give them time to reach it
toast('Message deleted', {
  action: {
    label: 'Undo',
    onClick: () => restoreMessage(id),
  },
  duration: 8000, // 8 seconds for keyboard users
  // Sonner also pauses on hover by default ✓
})

// For errors, you might want them to persist until dismissed
toast.error('Failed to process payment. Please try again.', {
  duration: Infinity, // stays until manually dismissed
})

Second: make your toast messages descriptive. 'Error' is not a toast message. 'Failed to save — check your connection and try again' is a toast message. This helps screen reader users too, who can't see the red color indicating an error state.

The Alternatives Worth Knowing About

There are a few other options in the ecosystem worth a quick mention:

  • Radix Primitives (Toast): Good if you're already deep in the Radix ecosystem. More markup, more control. We'd reach for Sonner first unless we needed the Radix accessibility guarantees for a very specific use case.
  • shadcn/ui Toast: Uses Radix under the hood. If your project is already using shadcn, this is a fine default. It's more verbose to use than Sonner but fits the shadcn design system better.
  • Native browser notifications (Notification API): Completely different use case — these show up outside the browser window. Useful for background tasks or apps that users leave open in a background tab. Requires permission, has its own UX challenges, not a replacement for in-app toasts.
  • Building your own: Please don't. We did once, learned about aria-live the hard way, then migrated to Sonner. The libraries exist for good reasons.

If you're using shadcn/ui in your project, there's a real choice to make between the built-in shadcn Toast and Sonner. Emil actually built a shadcn-compatible version of Sonner — you can add it via the shadcn CLI with `npx shadcn@latest add sonner`. It's the best of both worlds and honestly what we use in most peal.dev templates.

A Complete Working Pattern

Here's the full pattern we actually ship — a reusable hook that wraps async operations and handles toasts automatically, so you don't have to write the same try/catch/toast boilerplate in every component:

// hooks/use-async-action.ts
import { useState } from 'react'
import { toast } from 'sonner'

type Options<T> = {
  onSuccess?: (result: T) => void
  onError?: (error: Error) => void
  successMessage?: string
  errorMessage?: string
  loadingMessage?: string
}

export function useAsyncAction<TArgs extends unknown[], TResult>(
  action: (...args: TArgs) => Promise<TResult>,
  options: Options<TResult> = {}
) {
  const [isPending, setIsPending] = useState(false)

  const execute = async (...args: TArgs) => {
    setIsPending(true)

    const promise = action(...args)
      .then((result) => {
        options.onSuccess?.(result)
        return result
      })
      .finally(() => {
        setIsPending(false)
      })

    toast.promise(promise, {
      loading: options.loadingMessage ?? 'Loading...',
      success: options.successMessage ?? 'Done',
      error: (err) => options.errorMessage ?? err.message ?? 'Something went wrong',
    })

    return promise
  }

  return { execute, isPending }
}

// Usage in a component
const { execute: deleteUser, isPending } = useAsyncAction(
  (id: string) => fetch(`/api/users/${id}`, { method: 'DELETE' }).then(r => r.json()),
  {
    successMessage: 'User deleted',
    errorMessage: 'Failed to delete user',
    loadingMessage: 'Deleting user...',
    onSuccess: () => router.push('/users'),
  }
)

// <button onClick={() => deleteUser(userId)} disabled={isPending}>

This pattern has saved us a lot of repetitive code. One hook, consistent toast behavior across the app, easy to test, easy to change the toast library later if you need to.

Pick Sonner as your default. Switch to React Hot Toast if you need heavily custom toast JSX. Use the shadcn Sonner integration if you're in a shadcn project. Everything else is overthinking it.

We include Sonner with this kind of pattern pre-wired in the peal.dev templates — the Toaster is already in the root layout, there's a utility wrapper ready to go, and the position/duration defaults are set sensibly for both desktop and mobile. One less thing to figure out when you're trying to ship.

The main thing: whatever library you pick, spend 10 minutes auditing your toast usage before launch. Open your app, do every major action, and ask yourself: would someone who has never seen this app understand what each toast is telling them? If the answer is no for any of them, fix the message, not the library.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders