BuildWithMatija
  1. Home
  2. Blog
  3. Payload
  4. Replace window.confirm with Payload CMS ConfirmationModal

Replace window.confirm with Payload CMS ConfirmationModal

Swap browser confirm dialogs for Payload's native ConfirmationModal using useModal, slug scoping, and async onConfirm…

26th August 2026·Updated on:4th September 2026··
Payload
Replace window.confirm with Payload CMS ConfirmationModal

Evaluating Payload CMS Implementation Costs?

Scope design, content structure, and migration hours to estimate a realistic production timeline and hosting setup.

Try the Cost EstimatorGet a Second Opinion

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Contents

  • The pattern in three steps
  • 1. Open the modal with a slug, not the action
  • 2. Declare a ConfirmationModal for that slug
  • 3. Handle the confirm — call your API, then toast and refresh
  • Why slug modals per-entity, not per-action-type
  • Minimal copy-paste template
  • FAQ
  • Wrapping up
On this page:
  • The pattern in three steps
  • Why slug modals per-entity, not per-action-type
  • Minimal copy-paste template
  • FAQ
  • Wrapping up
Build with Matija logo

Build with Matija

Senior-led B2B websites, applications, content systems, and digital infrastructure. Business-first, full-stack, AI-assisted, no handoffs.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

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 strategyWhen it's fineWhere it breaks
Per action type ("reject")Single-record detail view, one instance of each buttonList/table views, dashboards with repeated actions
Per entity + action (`reject-${id}`)Any context, including repeated rowsNo 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 ConfirmationModals 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.

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

Thanks, Matija