Fix: Sonner Not Working — Toasts Not Showing, Styling Broken, or Server Component Errors
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 contentOr calling toast() in a Server Component throws:
Error: toast is not a function — or — Cannot call client function from serverWhy 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 loaded —
richColorsor explicit class overrides solve broken styling. toast()is client-only — Server Actions return data; clients calltoast()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 differentiateStill 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 work — toast() 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: cmdk Not Working — Command Palette Not Opening, Items Not Filtering, or Keyboard Navigation Broken
How to fix cmdk command palette issues — Dialog setup, custom filtering, groups and separators, keyboard shortcuts, async search, nested pages, and integration with shadcn/ui and Tailwind.
Fix: Conform Not Working — Form Validation Not Triggering, Server Errors Missing, or Zod Schema Rejected
How to fix Conform form validation issues — useForm setup with Zod, server action integration, nested and array fields, file uploads, progressive enhancement, and Remix and Next.js usage.
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.
Fix: Lingui Not Working — Messages Not Extracted, Translations Missing, or Macro Errors
How to fix Lingui.js i18n issues — setup with React, message extraction, macro compilation, ICU format, lazy loading catalogs, and Next.js integration.