Skip to content

Fix: Sonner Not Working — Toasts Not Showing, Styling Broken, or Server Component Errors

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix Sonner toast notification issues — Toaster setup, toast types, custom styling, promise toasts, action buttons, positioning, dark mode, and Next.js App Router integration.

The Problem

toast() is called but no notification appears:

import { toast } from 'sonner';

function handleSave() {
  toast('Document saved');
  // Nothing visible on screen
}

Or toasts render but with broken styling — no background, no border, invisible text:

Toast text appears but is transparent or overlaps other content

Or calling toast() in a Server Component throws:

Error: toast is not a function — or — Cannot call client function from server

Why This Happens

Sonner is a toast notification library for React that renders notifications from a central <Toaster /> component. The architecture is intentional and simple: toast() is a pure function that pushes a notification into a global queue; <Toaster /> is a React component that subscribes to that queue and renders the actual DOM. The two pieces are decoupled, which means a missing <Toaster /> produces no error, no console warning, and no DOM output — calls to toast() just queue notifications that nobody renders. This is the single most common reason toasts “don’t work” for new users.

The second class of failures is styling. Sonner ships its own CSS that is bundled into sonner and injected automatically when the <Toaster /> mounts in most setups. But some configurations strip or de-prioritize it: aggressive CSS-in-JS solutions that wipe non-managed styles, Tailwind layers that win the cascade with utility classes, and bundler setups that tree-shake the CSS export. The visible result is a toast that exists in the DOM but has no background, no border, transparent text, and overlaps the page. Adding the richColors prop forces stronger default backgrounds; for full control use toastOptions.classNames and apply your own Tailwind classes.

The third class is the boundary between Server and Client Components in Next.js App Router. toast() is client-only — it mutates a client-side store and requires the React DOM to render. Calling it from a Server Component or directly inside a Server Action body throws a build-time or runtime error. The correct pattern is to return a result object from the server action and call toast() from the client component that awaited the action — or to wrap the action call in toast.promise(), which fires the loading/success/error UI from the client based on promise state.

  • <Toaster /> must be mounted in the tree — without it, queued toasts have nowhere to render.
  • Default styles must be loadedrichColors or explicit class overrides solve broken styling.
  • toast() is client-only — Server Actions return data; clients call toast() based on that data.
  • toast.promise() is the bridge — for async work driven by server actions or fetches.

In Production: Incident Lens

When Sonner breaks in production, the blast radius is UX feedback. The user clicks “Save,” your code dutifully calls toast.success("Saved"), and nothing happens. The user believes the action failed, so they click again. Now the form submits twice, creating a duplicate record, a double-charge, or a race-condition write. Toasts may seem like cosmetic UI, but they’re the only acknowledgment of successful state changes in most SPAs — losing them silently degrades the entire app’s perceived reliability and produces a cascade of duplicate operations.

How it surfaces: support tickets describing “the save button doesn’t work” or “I had to click twice to update my profile.” Server logs reveal duplicate writes within milliseconds from the same session. Engineers reproduce locally and see toasts work fine, because the bug only manifests against the production build where a CSS purge or bundler optimization stripped Sonner’s styles, or where the layout was refactored and <Toaster /> was removed from the root tree.

Monitoring signal: a sharp uptick in duplicate POST requests from the same session within a 2-second window is the smoking gun for missing UI feedback. Tag your form submission events with a client-side submissionId and alert when the same id appears twice. For the styling regression, add a Playwright smoke test that triggers a known toast and asserts the rendered element has a non-transparent background — a one-line visual assertion that catches CSS purge regressions before deploy.

Recovery sequence: revert the offending UI commit. If the layout change is intentional, immediately add <Toaster /> to the new root. For styling regressions, the fastest patch is adding richColors and closeButton props to force-render a styled, dismissable toast even when project CSS conflicts. The postmortem preventive is two layered tests: a unit test that asserts <Toaster /> is in the root layout, and a visual regression test on a /test/toast page that triggers each toast variant and snapshots the result. Also wire a content-security-policy check — Sonner’s animations use inline styles, so a strict CSP without 'unsafe-inline' for style sources will silently strip the visual.

Fix 1: Basic Setup

npm install sonner
// app/layout.tsx — mount Toaster once at the root
import { Toaster } from 'sonner';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        {children}
        <Toaster />
      </body>
    </html>
  );
}
// Any client component — trigger toasts
'use client';

import { toast } from 'sonner';

function SaveButton() {
  function handleSave() {
    // Basic toast
    toast('Document saved successfully');

    // Success toast
    toast.success('Profile updated');

    // Error toast
    toast.error('Failed to save changes');

    // Warning toast
    toast.warning('Your session is about to expire');

    // Info toast
    toast.info('New version available');

    // With description
    toast.success('File uploaded', {
      description: 'resume.pdf has been uploaded to your documents',
    });

    // With duration (milliseconds)
    toast('Quick notification', { duration: 2000 });

    // Persistent toast (no auto-dismiss)
    toast('Important message', { duration: Infinity });
  }

  return <button onClick={handleSave}>Save</button>;
}

Fix 2: Promise Toasts (Loading → Success/Error)

'use client';

import { toast } from 'sonner';

function SubmitForm() {
  async function handleSubmit() {
    // Automatically shows loading, then success or error
    toast.promise(
      fetch('/api/submit', { method: 'POST', body: JSON.stringify(data) })
        .then(res => {
          if (!res.ok) throw new Error('Failed');
          return res.json();
        }),
      {
        loading: 'Submitting...',
        success: (data) => `Saved: ${data.name}`,
        error: (err) => `Error: ${err.message}`,
      }
    );
  }

  // Or with async function
  async function handleUpload(file: File) {
    toast.promise(uploadFile(file), {
      loading: `Uploading ${file.name}...`,
      success: 'Upload complete!',
      error: 'Upload failed. Please try again.',
    });
  }

  return <button onClick={handleSubmit}>Submit</button>;
}

Fix 3: Action Buttons and Custom Content

'use client';

import { toast } from 'sonner';

function NotificationExamples() {
  // Toast with action button
  function handleDelete() {
    const itemId = '123';

    toast('Item deleted', {
      action: {
        label: 'Undo',
        onClick: () => restoreItem(itemId),
      },
      duration: 5000,
    });

    deleteItem(itemId);
  }

  // Toast with cancel button
  function handleArchive() {
    toast('Archive this conversation?', {
      action: {
        label: 'Archive',
        onClick: () => archiveConversation(),
      },
      cancel: {
        label: 'Cancel',
        onClick: () => {},
      },
    });
  }

  // Custom JSX content
  function showCustomToast() {
    toast.custom((t) => (
      <div className="flex items-center gap-3 bg-white border rounded-lg p-4 shadow-lg">
        <img src="/avatar.jpg" className="w-10 h-10 rounded-full" />
        <div>
          <p className="font-medium">New message from Alice</p>
          <p className="text-sm text-gray-500">Hey, are you available?</p>
        </div>
        <button
          onClick={() => toast.dismiss(t)}
          className="ml-auto text-gray-400 hover:text-gray-600"
        >
          Close
        </button>
      </div>
    ));
  }

  // Dismiss specific or all toasts
  function dismissExamples() {
    const toastId = toast('Dismissable toast');
    toast.dismiss(toastId);   // Dismiss specific toast
    toast.dismiss();          // Dismiss all toasts
  }

  return (
    <div>
      <button onClick={handleDelete}>Delete</button>
      <button onClick={handleArchive}>Archive</button>
      <button onClick={showCustomToast}>Custom</button>
    </div>
  );
}

Fix 4: Toaster Configuration and Styling

import { Toaster } from 'sonner';

// Full configuration
<Toaster
  position="bottom-right"           // top-left, top-center, top-right,
                                     // bottom-left, bottom-center, bottom-right
  expand={false}                     // Expand toasts on hover
  richColors                         // Enhanced colors for success/error/warning
  closeButton                        // Show close button on all toasts
  duration={4000}                    // Default duration (ms)
  theme="system"                     // 'light' | 'dark' | 'system'
  visibleToasts={3}                  // Max visible at once
  offset="16px"                      // Distance from viewport edge
  dir="ltr"                          // Text direction

  // Custom styles
  toastOptions={{
    className: 'my-toast',
    style: {
      background: '#1a1a2e',
      color: '#ffffff',
      border: '1px solid #333',
    },
    // Per-type styling
    classNames: {
      success: 'bg-green-50 border-green-200 text-green-800',
      error: 'bg-red-50 border-red-200 text-red-800',
      warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
      info: 'bg-blue-50 border-blue-200 text-blue-800',
    },
  }}
/>

// Tailwind dark mode
<Toaster
  theme="system"
  toastOptions={{
    classNames: {
      toast: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700',
      title: 'text-gray-900 dark:text-gray-100',
      description: 'text-gray-500 dark:text-gray-400',
      actionButton: 'bg-blue-500 text-white',
      cancelButton: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300',
    },
  }}
/>

Fix 5: Server Action Integration

// app/actions.ts
'use server';

export async function updateProfile(formData: FormData) {
  const name = formData.get('name') as string;

  try {
    await db.update(users).set({ name }).where(eq(users.id, currentUser.id));
    return { success: true, message: 'Profile updated' };
  } catch {
    return { success: false, message: 'Failed to update profile' };
  }
}
// components/ProfileForm.tsx
'use client';

import { toast } from 'sonner';
import { updateProfile } from '@/app/actions';

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

    if (result.success) {
      toast.success(result.message);
    } else {
      toast.error(result.message);
    }
  }

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

// Or use toast.promise with server action
function PromiseForm() {
  function handleSubmit(formData: FormData) {
    toast.promise(updateProfile(formData), {
      loading: 'Saving...',
      success: (result) => result.message,
      error: 'Something went wrong',
    });
  }

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

Fix 6: Headless Mode and Testing

// Headless — no default styles, full control
import { toast, Toaster } from 'sonner';

<Toaster
  toastOptions={{
    unstyled: true,  // Remove all default styles
    classNames: {
      toast: 'flex items-center gap-2 p-4 rounded-xl shadow-xl border',
      title: 'font-semibold',
      description: 'text-sm opacity-70',
      actionButton: 'px-3 py-1 rounded bg-blue-500 text-white text-sm',
      cancelButton: 'px-3 py-1 rounded bg-gray-200 text-sm',
      success: 'bg-green-50 border-green-200',
      error: 'bg-red-50 border-red-200',
    },
  }}
/>

// Multiple Toaster instances (different positions)
// Top for errors, bottom for success
<Toaster position="top-center" toastOptions={{ classNames: { error: 'top-toast' } }} />
// Note: Sonner supports only one Toaster — use toast options to differentiate

Still Not Working?

Toast is called but nothing appears<Toaster /> is missing from your component tree. Add it to your root layout. It only needs to be mounted once. Verify by adding <Toaster /> directly next to the button that calls toast() — if it works there but not in the layout, there’s a rendering issue with your layout component.

Toasts appear but are invisible (transparent) — Sonner’s styles might not be loading. Add richColors to the <Toaster /> for more visible default styles. If using Tailwind, Sonner’s default styles may conflict. Use the toastOptions.classNames to apply Tailwind classes explicitly.

toast() in Server Action doesn’t worktoast() is client-side only. Server Actions run on the server. Return a result from the action, then call toast() in the client component based on that result. The toast.promise() pattern works because the promise resolves on the client.

Toasts stack indefinitely — set visibleToasts={3} on <Toaster /> to limit visible toasts. Excess toasts queue and appear as previous ones dismiss. Also set a reasonable duration — the default is 4000ms.

Tailwind v4 strips Sonner’s styles — Tailwind v4’s @layer ordering can win over Sonner’s injected CSS. Move the <Toaster /> mount below your global CSS import, or set unstyled: true plus explicit toastOptions.classNames so Sonner contributes no opinionated styles to compete with. Either way, run a production build (next build) before declaring it fixed — Tailwind only purges in production.

Strict CSP blocks Sonner animations — Sonner uses small inline style attributes for the slide-in animation. A strict CSP without style-src 'self' 'unsafe-inline' strips them, leaving toasts visible but unanimated and stuck off-screen. Either allow 'unsafe-inline' for style sources or pre-render the animation classes and reference them by class name.

Duplicate toasts on every Server Action — using toast() inside both the action result handler and a toast.promise() wrapper around the same call fires two notifications. Pick one path. The cleanest pattern is toast.promise(action(formData), { ... }) and remove any manual toast.success/toast.error calls that follow.

For related UI issues, see Fix: Radix UI Not Working, Fix: shadcn/ui Not Working, Fix: Next.js Server Action Not Working, and Fix: Tailwind v4 Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles