Toast notifications in Next.js: when and how
Add toast notifications to Next.js: mount one Toaster, call toast() from client components and after server actions, add undo, and keep toasts accessible.
By MiniDev11 min read
To add toast notifications in Next.js, render one <Toaster /> in the root layout and call toast() from client components, including right after a server action returns. Server code cannot show a toast, so the action returns a result and the client decides what to say. Use toasts for brief confirmations of something the user just did, and use an inline alert or a banner for errors that need fixing or states that persist.
Mount the Toaster once
npx shadcn@latest add https://ui.minidev.pro/r/toast.json
The toast file exports four things: Toast (one card), ToastStack (a static column of cards for mockups), Toaster (the live region that renders queued toasts) and toast (the function you call). Toaster is a client component, and a server layout can render it directly:
import { Toaster } from "@/components/ui/toast"
import "./globals.css"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Toaster />
</body>
</html>
)
}
- Put it in the root layout, not a page. Layouts persist across client-side navigation, so a toast fired just before
router.pushstays on screen on the next page. - Mount exactly one. The queue is module-level state, so two Toasters would render every toast twice.
- It is fixed to the bottom right with a 1rem inset and
z-[60], above dialogs. PassclassNameto change the inset, for examplebottom-20to clear a mobile tab bar. - Up to six toasts are kept and three are visible as a stack. Hover or focus fans them out and pauses their timers.
Call toast() from client components
The API follows the shape most React developers know from Sonner. Every call returns a numeric id.
import { toast } from "@/components/ui/toast"
toast("Link copied")
toast.success("Invite sent", { description: "sam@acme.co will get an email in a minute." })
toast.warning("You are near your seat limit", { duration: 8000 })
toast.error("Could not save changes", { description: "Check your connection and try again." })
const id = toast("Uploading 3 files")
toast.dismiss(id)
toast.promise(exportCsv(), {
loading: "Preparing export",
success: (file) => "Exported " + file.rows + " rows",
error: "Export failed",
})
| Option | Type | Default |
|---|---|---|
description | ReactNode | none |
action | { label: string; onClick: () => void } | none |
duration | milliseconds, or Infinity to stay until dismissed | 4000 |
toast.promise shows a spinner toast that never times out while pending, then turns into a success toast (4 seconds) or an error toast (5 seconds) in place, so the stack does not grow. It returns the original promise, so you can still await it. Its messages are titles only; there is no description option on promise toasts.
Toasts after server actions
A server action runs on the server and cannot reach the browser's toast queue. Return a small result object and let the client react. Return expected failures instead of throwing them: in production, Next.js replaces the message of a thrown error with a generic one, so a thrown "Name already taken" never reaches the user.
"use server"
import { revalidatePath } from "next/cache"
export type Result = { ok: true; id: string } | { ok: false; error: string }
export async function createProject(formData: FormData): Promise<Result> {
const name = String(formData.get("name") ?? "").trim()
if (!name) return { ok: false, error: "Give the project a name." }
if (await projectExists(name)) return { ok: false, error: "A project with that name already exists." }
const project = await db.project.create({ data: { name } })
revalidatePath("/projects")
return { ok: true, id: project.id }
}
"use client"
import { useRouter } from "next/navigation"
import { toast } from "@/components/ui/toast"
import { FormField } from "@/components/ui/form-field"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { createProject } from "./actions"
import { useState } from "react"
export function NewProjectForm() {
const router = useRouter()
const [error, setError] = useState<string>()
return (
<form
action={async (formData) => {
const res = await createProject(formData)
if (!res.ok) return setError(res.error)
toast.success("Project created")
router.push("/projects/" + res.id)
}}
className="space-y-3"
>
<FormField id="name" label="Project name" error={error}>
<Input id="name" name="name" aria-invalid={!!error} />
</FormField>
<Button type="submit">Create project</Button>
</form>
)
}
Note the split: the validation error goes inline under the field, where the user is looking and where it stays until fixed; the success goes in a toast because the user is about to leave the form. If the action itself calls redirect(), do not rely on code after the await to show a toast, because the navigation is part of the action's response. Either return data and navigate from the client as above, or pass a flag in the URL (?created=1) and show the toast from a small client component on the next page that reads it with useSearchParams and then removes it with router.replace. Guard that effect with a ref, since React runs effects twice in development Strict Mode.
Tones and when to use each
| Call | Tone | Role | Use for |
|---|---|---|---|
toast() | neutral | status | Facts: copied, moved, queued |
toast.success() | success | status | A completed action the user asked for |
toast.warning() | warning | status | Worked, with a caveat worth knowing |
toast.error() | danger | alert | Background failures the user must know about |
toast.promise() | loading, then success or danger | status or alert | Work that takes more than a second |
Keep titles to a few words in past tense ("Invite sent"), and add a description only when it tells the user something new. Do not toast things the UI already shows: if a row visibly appears in a table, "Row added" is noise.
Actions and undo
Undo is the best reason to put a button in a toast. It lets you skip a confirmation dialog for reversible actions. Perform the action immediately (for example a soft delete), then offer to reverse it. The action button does not dismiss the toast by itself, so dismiss it in the handler:
async function archive(project: Project) {
await archiveProject(project.id)
const id = toast("Project archived", {
description: project.name,
duration: 8000,
action: {
label: "Undo",
onClick: async () => {
toast.dismiss(id)
await restoreProject(project.id)
toast.success("Project restored")
},
},
})
}
Give toasts with actions a longer duration. Four seconds is enough to read "Saved", not enough to find and press Undo, especially with a keyboard.
Accessibility: announce, do not interrupt
A toast notification is announced, not focused. The Toaster's list is an aria-live="polite" region, and each card carries role="status", or role="alert" for the danger tone, so screen readers read new toasts without moving focus (see ARIA for how live regions work). What the component handles and what is up to you:
- Focus is never stolen. Toasts appear without taking focus, so typing in a field is not interrupted.
- Timers pause while the pointer is over the stack or focus is inside it, and the dismiss button appears on hover or keyboard focus.
- Timing is your call. WCAG asks that people get enough time to read and act. Give errors and anything with an action a longer duration, or
Infinityfor messages that must be acknowledged. - Never make a toast the only way to do something. Keyboard and screen reader users may not reach it before it disappears. Undo should also exist elsewhere, such as an Archived view with a Restore button.
- Do not stack announcements. Ten rapid toasts produce ten announcements. Batch them: "12 files uploaded" instead of twelve toasts.
When a banner or inline alert is better
Toasts are transient and detached from context. Anything that needs fixing or persists belongs in the page:
| Situation | Use | Why |
|---|---|---|
| Form field is invalid | FormField error | Next to the field, stays until fixed |
| Form submit failed | InlineAlert | At the form, with what to do |
| Payment failed, trial ending, outage | Banner | Persistent state across pages |
| Static guidance on a page | Callout | Part of the content, not an event |
| Irreversible action | Confirmation dialog | Needs a decision before it happens |
| Background job finished | Toast | Brief, and the user may be elsewhere |
<Banner tone="warning" action={<Button size="sm" variant="outline" render={<Link href="/billing" />}>Update card</Button>}>
Your last payment failed. Update your card to keep your workspace active.
</Banner>
<InlineAlert tone="danger" title="Could not connect to Stripe">
Check that the API key has write access, then try again.
</InlineAlert>
InlineAlert uses role="alert", so render it when the error happens rather than keeping a hidden one on the page. Banner uses role="status" and takes an optional onDismiss; only make it dismissible if the state it describes is not urgent.
MiniDev toast or Sonner
Sonner is an excellent package with more features: configurable positions, swipe to dismiss, custom JSX toasts, toast.loading, and updating toasts by id. MiniDev's toast is a single file you own, drawn with the same tokens and shadows as the rest of the kit, with the calls most apps use: toast, success, error, warning, promise and dismiss. Choose Sonner when you need its extras, and MiniDev's when you want the toast to match the kit and be editable in place. Because the call signatures are similar, switching later is mostly an import change.
Components used
Toast, InlineAlert, Banner and Callout are free under MIT in the feedback category. The toast pairs naturally with the save patterns in the settings page guide and the plan changes in the Stripe billing guide. The MiniDev studio builds complete products on this kit if you need more than the parts.
ToastuiToast notifications with a toast() API for success, error, warning and promise states. The Toaster stacks cards, fans them out on hover and pauses timers.npx shadcn@latest add ui.minidev.pro/r/toast.jsonInline AlertuiAn inline React message box in neutral, success, warning and danger tones with a matching icon, an optional title and body text. Announced with role alert.npx shadcn@latest add ui.minidev.pro/r/inline-alert.jsonBanneruiInline notice banner in accent, warning or danger tones, with a status role, a slot for an action button and an optional dismiss icon on the right.npx shadcn@latest add ui.minidev.pro/r/banner.jsonnpx shadcn@latest add https://ui.minidev.pro/r/toast.json https://ui.minidev.pro/r/inline-alert.json https://ui.minidev.pro/r/banner.json https://ui.minidev.pro/r/callout.json
Components used in this guide
Frequently asked questions
Can I show a toast from a Next.js server action?
Not directly. The toast queue lives in the browser. Return a result from the action and call toast.success or toast.error in the client code that awaited it, or pass a flag in the URL when the action redirects.
Where should the Toaster go in the App Router?
In the root app/layout.tsx, rendered once inside body. Layouts persist across client navigation, so toasts survive router.push.
Are toast notifications accessible?
They can be. Announce them through a polite live region, never move focus to them, pause timers on hover and focus, give actionable toasts enough time, and make sure any action in a toast is also available elsewhere.
Should form validation errors be toasts?
No. Show them inline next to the field or at the top of the form, where they stay until fixed. Toasts suit brief confirmations and background events.