Skip to content

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

FixDevs ·

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:

  • <Toaster /> must be mounted in the component treetoast() queues notifications, but they’re rendered by the <Toaster /> component. Without it in your layout, notifications have nowhere to render. This is the most common issue.
  • Sonner ships minimal default styles — the toast styles depend on Sonner’s CSS being loaded. Some bundler configurations or CSS-in-JS setups strip the styles. The <Toaster /> component accepts a theme prop and custom className overrides.
  • toast() is a client-side function — it can’t be called directly in Server Components or Server Actions. Trigger toasts from client components or pass success/error state from server to client.

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

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

For related UI issues, see Fix: Radix UI Not Working and Fix: shadcn/ui 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