WHAT YOU'LL LEARN
  • What a Headless CMS entries bulk action is
  • How Webiny turns a bulk action into a background task and a GraphQL mutation
  • How to implement the API-side bulk action (loadData / processData)
  • How to add the Admin-side button that triggers it
  • How to make the task converge and finish
  • How to notify the user in real time as entries are processed

The APIs on this page are available from Webiny 6.5.0. The bulk action itself is imported from webiny/api/cms/entry.

Overview
anchor

A bulk action lets an editor select multiple entries in the Headless CMS entry list and run a single operation over all of them — for example, apply a discount to every selected product.

The important thing to understand is what Webiny does with a bulk action you register. For every EntriesBulkAction, the framework automatically generates:

  • a pair of background tasks (a “list” task that paginates the matching entries, and a “process” task that runs once per entry, in batches), and
  • a GraphQL mutation that the Admin app calls to trigger those tasks.

So you never schedule, chunk, retry, or resume-on-timeout anything yourself — that machinery comes from the background tasks system. You only describe what to do: which entries to operate on, and what to do to each one.

A full-stack bulk action has two parts:

  1. API — the EntriesBulkAction implementation (the background-task body).
  2. Admin — a button in the entry list that triggers it.

The example below implements an “Apply Discount” bulk action on a product model.

The API-Side Bulk Action
anchor

You implement EntriesBulkAction.Interface and register it with EntriesBulkAction.createImplementation(). The interface has two methods:

  • loadData(model, params) — collect the entries to operate on. Runs in the “list” task.
  • processData(model, params) — operate on a single entry. Runs in the “process” task.
extensions/applyDiscount/api/ApplyDiscountBulkAction.ts

Making the Task Converge
anchor

This is the single most important detail. The tasks engine calls loadData repeatedly — after each processing round it re-lists to check whether more work remains, and it stops only when loadData returns zero entries.

That means your filter must exclude entries that have already been processed, otherwise the task never converges: it would re-discount the same products forever and eventually fail with a maxIterations error.

In the example, processData sets onSale: true, and loadData filters those out with "values.onSale_not": true. Each round shrinks the working set until nothing is left, and the task finishes.

Storage-level `where`, not GraphQL `where`

The bulk-action list path talks to storage directly and bypasses the GraphQL where-transform. At the storage layer, custom fields are namespaced under values. (system fields like id stay top-level), so you write the filter as "values.onSale_not". A bare onSale_not cannot be resolved and throws “There is no field with the fieldId onSale”.

skipValidation
anchor

processData updates the entry with { skipValidation: true } because this is a targeted, system-driven field update — it should not fail just because some unrelated field on the entry happens to be empty or invalid. Full validation still runs when a human edits the entry in the Admin app.

Success and Failure per Entry
anchor

Inside processData, throwing marks that single entry as failed in the task report; returning marks it done. The rest of the batch keeps going either way.

The Admin-Side Button
anchor

The button lives in the content entry list and triggers the background task. The browser does not loop over entries — it hands the whole selection to the API and the task processes it server-side. The user can navigate away while it runs.

extensions/applyDiscount/admin/ApplyDiscountAction.tsx

A few things to note:

  • useFeature(BulkActionFeature) resolves the use case whose execute() fires the generated GraphQL mutation.
  • action: "ApplyDiscount" is the PascalCased form of the API name ("applyDiscount").
  • where is the selection scope. Pass { id_in: [...] } for an explicit selection, or undefined when the user chose “select all” across pages, so the task processes everything matching the current view.
  • data is the arbitrary payload delivered to processData as params.data (here, the discount percentage).

Registering the Bulk Action
anchor

On the Admin side, register the button in ContentEntryListConfig with a Browser.BulkAction whose name matches the API name:

extensions/applyDiscount/admin/Extension.tsx

Then wire both sides into your project with a single extension component and register it in webiny.config.tsx:

extensions/applyDiscount/ApplyDiscountExtension.tsx
webiny.config.tsx

Monitoring the Task
anchor

Because a bulk action is a background task, you monitor and troubleshoot it exactly like any other task — list runs, inspect input/output, read logs, or abort it. See Background Tasks for the available GraphQL queries and mutations.

Real-Time Notifications (Optional)
anchor

Since processing happens in the background, it’s nice to tell the user as each entry completes. You can emit a websocket message from processData and toast it in the Admin app. See Generate AI Summary Bulk Action for a complete example that adds per-entry websocket notifications on top of the pattern shown here.