---
title: "Replace window.confirm with Payload CMS ConfirmationModal"
slug: "replace-window-confirm-payload-confirmationmodal"
published: "2026-08-26"
updated: "2026-09-04"
validated: "2026-08-29"
categories:
  - "Payload"
tags:
  - "Payload CMS ConfirmationModal"
  - "useModal"
  - "@payloadcms/ui"
  - "window.confirm replacement"
  - "slug-based modal"
  - "async onConfirm"
  - "idempotencyKey"
  - "next/navigation router.refresh"
  - "admin confirm dialog"
  - "custom admin components"
  - "ConfirmationModal example"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "payload@3"
  - "@payloadcms/ui@latest"
  - "next@15"
  - "react@19"
  - "node@18+"
status: "stable"
llm-purpose: "Payload CMS ConfirmationModal: replace window.confirm with a styled, async admin dialog. Learn slug-based modals, onConfirm patterns, and copy-paste code…"
llm-prereqs:
  - "Access to Payload CMS"
  - "Access to @payloadcms/ui"
  - "Access to React"
  - "Access to Next.js"
  - "Access to next/navigation"
llm-outputs:
  - "Completed outcome: Payload CMS ConfirmationModal: replace window.confirm with a styled, async admin dialog. Learn slug-based modals, onConfirm patterns, and copy-paste code…"
---

**Summary Triples**
- (useModal, provides, openModal(slug) to open globally-managed modals)
- (openModal, accepts, a unique slug string; use entity-scoped slugs (e.g., approval-request-{id}-reject))
- (ConfirmationModal, is tied to, a slug via its modalSlug prop and renders when openModal(slug) is called)
- (ConfirmationModal, handles, open/close state and confirm-button loading spinner automatically)
- (onConfirm, supports, async handlers; returning a Promise keeps the confirm button in loading state)
- (idempotencyKey, prevents, duplicate side effects when repeated confirm submissions occur)
- (slug scoping, reduces, race conditions by scoping modals to specific entity IDs)
- (next/navigation router.refresh, is used to, update the admin view after a successful confirm action)
- (pattern, replaces, window.confirm() with a styled in-app ConfirmationModal for consistent admin UX)
- (implementation, requires, declaring <ConfirmationModal modalSlug={slug} .../> in the component tree near the triggering button)

### {GOAL}
Payload CMS ConfirmationModal: replace window.confirm with a styled, async admin dialog. Learn slug-based modals, onConfirm patterns, and copy-paste code…

### {PREREQS}
- Access to Payload CMS
- Access to @payloadcms/ui
- Access to React
- Access to Next.js
- Access to next/navigation

### {STEPS}
1. Open a modal by slug
2. Declare a ConfirmationModal component
3. Implement onConfirm with async logic
4. Scope slugs per entity
5. Refresh and surface results

<!-- llm:goal="Payload CMS ConfirmationModal: replace window.confirm with a styled, async admin dialog. Learn slug-based modals, onConfirm patterns, and copy-paste code…" -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to @payloadcms/ui" -->
<!-- llm:prereq="Access to React" -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:prereq="Access to next/navigation" -->
<!-- llm:output="Completed outcome: Payload CMS ConfirmationModal: replace window.confirm with a styled, async admin dialog. Learn slug-based modals, onConfirm patterns, and copy-paste code…" -->

# Replace window.confirm with Payload CMS ConfirmationModal
> Payload CMS ConfirmationModal: replace window.confirm with a styled, async admin dialog. Learn slug-based modals, onConfirm patterns, and copy-paste code…
Matija Žiberna · 2026-08-26

Payload CMS ships a `ConfirmationModal` component and a `useModal()` hook in `@payloadcms/ui` that together give you an in-app confirm dialog matching the rest of the admin UI — no `window.confirm()`, no custom modal state machine. Wire a button to `openModal(slug)`, declare a `<ConfirmationModal modalSlug={slug}>` next to it, and Payload handles opening, closing, and the confirm-button's loading spinner for you.

I ran into this while building a multi-step approval workflow for a client's Payload 3 project — a custom admin view where reviewers approve, reject, request changes on, withdraw, or cancel a submission. Every one of those actions is destructive-ish or hard to undo, so each needed a "are you sure?" step with an optional comment field. `window.confirm()` was never going to look right sitting inside a styled admin panel, and building a custom modal from scratch for five separate actions felt like overkill. Payload already ships the primitive — it's just not obviously documented as "the confirm dialog."

## The pattern in three steps

### 1. Open the modal with a slug, not the action

Your button doesn't run the action directly. It opens a modal identified by a unique string slug.

```tsx
// File: src/payload/admin-components/approval/ApprovalRequestView.tsx
import { useModal } from "@payloadcms/ui";

const { openModal } = useModal();

<Button
  buttonStyle="error"
  onClick={() => openModal(`approval-request-${requestId}-reject`)}
>
  Reject
</Button>
```

`openModal` comes from Payload's global modal controller — you don't need to track open/closed state yourself. Slugs are just strings, so anything unique works, but scope them to the entity the action applies to (more on why in the next section).

### 2. Declare a ConfirmationModal for that slug

Somewhere in the same component tree (it doesn't have to be right next to the button — it just needs the same slug), render a `ConfirmationModal`. It's always mounted; it only becomes visible when its `modalSlug` matches an open call.

```tsx
// File: src/payload/admin-components/approval/request-view/DecisionModals.tsx
import { ConfirmationModal, TextareaInput } from "@payloadcms/ui";

<ConfirmationModal
  modalSlug={`approval-request-${requestId}-reject`}
  heading="Reject this approval request?"
  body={
    <TextareaInput
      label="Comment (optional)"
      path="reject-comment"
      rows={4}
      value={comment}
      onChange={(e) => setComment(e.target.value)}
    />
  }
  confirmLabel="Reject"
  confirmingLabel="Rejecting…"
  onConfirm={() => runReviewAction("reject")}
/>
```

`body` accepts any React node, so it's not limited to plain confirmation text — a `TextareaInput` for an optional reason, a warning banner, a summary of what's about to change, whatever the action needs. `confirmLabel` and `confirmingLabel` give you the idle and in-flight button text for free; you don't need your own `isSubmitting` state just to swap "Reject" for "Rejecting…".

### 3. Handle the confirm — call your API, then toast and refresh

`onConfirm` is where the actual work happens. Payload doesn't assume anything about what "confirm" means beyond running your callback and managing the modal's loading state while it's in flight.

```ts
// File: src/payload/admin-components/approval/ApprovalRequestView.tsx
import { toast } from "@payloadcms/ui";
import { useRouter } from "next/navigation";

const router = useRouter();

async function runReviewAction(action: "approve" | "reject" | "request_changes") {
  try {
    await actOnApprovalRequest({
      action,
      requestId,
      comment: comment.trim() || undefined,
      expectedUpdatedAt,
      idempotencyKey: generateIdempotencyKey(),
    });
    toast.success("Done.");
    router.refresh();
  } catch (error) {
    toast.error(error instanceof Error ? error.message : "Action failed.");
  }
}
```

Two things worth calling out here. First, `expectedUpdatedAt` — passing the record's last-known `updatedAt` to your API lets the server reject the action with a 409 if the record changed since the page loaded, which matters a lot when multiple reviewers can act on the same request. Second, `router.refresh()` after a successful action reloads server-rendered state so the view reflects the new status immediately, rather than trusting client-side optimistic state that could drift from what actually happened on the server.

## Why slug modals per-entity, not per-action-type

It's tempting to key modals by action name alone — one `"reject"` slug, reused everywhere. That breaks the moment the same action type appears more than once on a page: a list view with a reject button per row, or a dashboard rendering multiple pending requests. Every row would open the same modal instance, and confirming would apply to whichever row happened to set the shared state last — not necessarily the one the user clicked.

Scoping the slug to the record it acts on, like `approval-request-${requestId}-reject`, avoids that entirely. Each row gets its own modal instance with its own identity, so there's no shared state to collide.

| Slug strategy | When it's fine | Where it breaks |
|---|---|---|
| Per action type (`"reject"`) | Single-record detail view, one instance of each button | List/table views, dashboards with repeated actions |
| Per entity + action (`` `reject-${id}` ``) | Any context, including repeated rows | No real downside — slightly more string interpolation |

Default to per-entity slugs even on a detail page where you think you'll never have duplicates. Pages grow, and a reused-string slug bug only shows up once someone adds a second instance of the component months later.

## Minimal copy-paste template

Stripped down to the essentials, here's the whole pattern in one place:

```tsx
// File: src/payload/admin-components/example/ConfirmActionButton.tsx
import { Button, ConfirmationModal, toast, useModal } from "@payloadcms/ui";
import { useRouter } from "next/navigation";

export function ConfirmActionButton({ id }: { id: string }) {
  const { openModal } = useModal();
  const router = useRouter();
  const slug = `my-action-${id}`;

  return (
    <>
      <Button onClick={() => openModal(slug)}>Do thing</Button>

      <ConfirmationModal
        modalSlug={slug}
        heading="Do thing?"
        body={<p>Are you sure? This can&apos;t be undone.</p>}
        confirmLabel="Confirm"
        confirmingLabel="Confirming…"
        onConfirm={async () => {
          try {
            await myApiCall(id);
            toast.success("Done.");
            router.refresh();
          } catch (error) {
            toast.error(error instanceof Error ? error.message : "Action failed.");
          }
        }}
      />
    </>
  );
}
```

Drop this into any custom Payload admin component — a custom list column, a document view, a dashboard widget — and swap `myApiCall` for whatever your action actually does.

## FAQ

**Does `ConfirmationModal` block the page like `window.confirm()` does?**
No — it's a React modal overlay, not a blocking browser dialog. The rest of the page stays interactive underneath it (though visually obscured by the modal backdrop), and your app's state and event loop keep running normally.

**Can I have multiple `ConfirmationModal`s open at once?**
Only one modal is visible at a time by design — opening a new slug replaces whichever modal is currently showing. This is a UI constraint, not a technical limit on how many `ConfirmationModal` components you can declare.

**Do I need to close the modal manually after `onConfirm` runs?**
No, for the success path. Payload closes the modal automatically once your `onConfirm` promise resolves. If it rejects, the modal stays open with `confirmLabel` restored, so the user can retry — you handle the error (e.g., a toast) without needing to also manage the modal's open state.

**Can I use `ConfirmationModal` outside of Payload's admin UI, in my own Next.js app?**
`@payloadcms/ui` is designed for the Payload admin panel and pulls in its styling/theme context, so it's meant for custom admin components, not your site's public-facing frontend. For a public app you'd want a general-purpose modal/dialog library instead.

**What happens if the user's session expires or the request fails with something other than a normal error?**
That's on your `onConfirm` implementation — `ConfirmationModal` just awaits whatever promise you give it. Wrap your API call in a try/catch (as shown above) and surface failures with `toast.error`, and consider checking for auth-specific error shapes if your API distinguishes them.

## Wrapping up

`ConfirmationModal` plus `useModal()` gets you a properly styled, admin-matching confirm dialog with async support and a built-in loading state, without writing your own modal component or state machine. The only real decision to get right up front is slugging modals per-entity so they don't collide once the same action shows up more than once on a page — everything else is filling in `heading`, `body`, and `onConfirm` for whatever action you're gating.

For another small, practical Admin Panel customization, see [Payload CMS `admin.hidden` vs `access.read`](/blog/payload-admin-hidden-vs-access-read-hide-collections).

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks,
Matija

## LLM Response Snippet
```json
{
  "goal": "Payload CMS ConfirmationModal: replace window.confirm with a styled, async admin dialog. Learn slug-based modals, onConfirm patterns, and copy-paste code…",
  "responses": [
    {
      "question": "What does the article \"Replace window.confirm with Payload CMS ConfirmationModal\" cover?",
      "answer": "Payload CMS ConfirmationModal: replace window.confirm with a styled, async admin dialog. Learn slug-based modals, onConfirm patterns, and copy-paste code…"
    }
  ]
}
```