npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@devxcommerce/strapi-plugin-field-sync

v1.1.0

Published

Copy selected field, component, and dynamic-zone values from one entry onto many entries of the same collection type, from the Content Manager edit view

Readme

@devxcommerce/strapi-plugin-field-sync

Strapi v5 plugin for copying field values from a source entry onto one or more target entries of the same content type. Designed for the content-management workflow where editors maintain N near-identical landing pages, products, or collections and need to propagate a change made on one record to a curated list of others without leaving the admin panel.


Installation

bun add @devxcommerce/strapi-plugin-field-sync
# or: npm install @devxcommerce/strapi-plugin-field-sync

The plugin is auto-discovered via its strapi.kind marker — no resolve path needed. Enable it (and optionally pass config) in config/plugins.js; see Configuration.


Table of contents

  1. High-level flow
  2. Architecture
  3. HTTP routes
  4. Security & permissions
  5. Server services
  6. Admin UI
  7. Field path encoding
  8. Configuration
  9. Per-content-type label resolution
  10. Build & development
  11. Limitations & known caveats

High-level flow

The plugin renders an Edit-View side panel named Field Sync on every collection-type entry in the Content Manager. From that panel, the editor launches a three-step modal:

| Step | Component | Purpose | | ---- | --------- | ------- | | 1 | StepTargetEntries | Search & select the target entries to receive the synced data. | | 2 | StepFieldSelector | Pick which fields/components/dynamic-zone blocks to copy from the source. | | 3 | StepConfirmation | Review summary, dispatch POST /apply, surface per-target results. |

The server resolves the source entry, builds a JSON patch per target containing only the selected paths, and writes each target via the Strapi Documents API.


Architecture

src/plugins/field-sync/
├── admin/                       # React admin extension
│   └── src/
│       ├── index.ts             # registers Edit-View side panel
│       ├── components/
│       │   ├── FieldSyncPanel.tsx        # side-panel button + modal trigger
│       │   └── FieldSyncModal/
│       │       ├── index.tsx             # 3-step wizard router
│       │       ├── StepTargetEntries.tsx
│       │       ├── StepFieldSelector.tsx
│       │       └── StepConfirmation.tsx
│       └── utils/
│           ├── buildFieldTree.ts         # annotates schema with current values
│           └── extractPaths.ts           # collapses checked-set → wire paths
└── server/                      # Strapi plugin server
    └── src/
        ├── config/index.ts              # plugin config schema
        ├── routes/index.ts              # HTTP routes
        ├── controllers/field-sync.ts    # request handlers
        └── services/
            ├── schema.ts                # introspects content-types into FieldNode tree
            ├── entries.ts               # paginated list + populated single-entry fetch
            └── apply.ts                 # source→target patch builder + writer

The admin side talks to the server exclusively over the HTTP routes mounted under /field-sync (see below). No state is shared other than the Strapi documents API used by both reads and writes.


HTTP routes

All routes are mounted under the plugin prefix /field-sync as admin routes (require an authenticated admin session) and run a permission policy (see Security & permissions).

| Method | Path | Handler | Auth | Description | | ------ | ---- | ------- | ---- | ----------- | | GET | /schema/:uid | field-sync.getSchema | read | FieldNode[] tree of every syncable attribute. | | GET | /entries/:uid?page=&pageSize=&search=&excludeId= | field-sync.getEntries | read | Paginated rows { documentId, label, status } + resolved labelField. | | GET | /entries/:uid/:id | field-sync.getEntry | read | One entry, fully populated. | | POST | /preview (body: {uid, sourceDocumentId, targetIds[], selectedPaths[]}) | field-sync.preview | read | Per-target { documentId, changes: [{ path, before, after }] } — the diff shown before applying. Writes nothing. | | POST | /apply (body: {uid, sourceDocumentId, targetIds[], selectedPaths[], status?}) | field-sync.apply | update | Copies selectedPaths from the source onto each target. Returns { results: [{ documentId, status, error?, snapshot? }] }. | | POST | /restore (body: {uid, targets: [{documentId, data, wasPublished?}], status?}) | field-sync.restore | update | Writes snapshots back (the undo path). When status: 'published', also reverts the published version (republish/unpublish per wasPublished). |


Security & permissions

field-sync writes through the Documents API, which bypasses the Content Manager's RBAC layer — so the plugin re-imposes it. Every route is an admin route and runs a policy that checks the caller's Content-Manager ability on the target content type:

  • read endpoints require plugin::content-manager.explorer.read,
  • write endpoints (/apply, /restore) require plugin::content-manager.explorer.update.

The result: a user can only sync to content types they could already edit natively. Client-supplied field paths are additionally validated server-side against the live schema before any write, and config-excluded fields are stripped — so a stale or forged client cannot write fields the UI would block.

Auditing

No separate integration is required to audit syncs. field-sync writes through the Documents API (documents().update / .publish), so if the @devxcommerce/strapi-plugin-audit-log plugin is installed, its document-service middleware already records each sync as entry.update / entry.publish events attributed to the acting admin. For a higher-level signal, field-sync also emits a field-sync.applied event and a structured log line per apply (who · uid · source · targets · fields · status · ok/fail).


Server services

schema (server/src/services/schema.ts)

Introspects strapi.contentTypes[uid].attributes and produces a FieldNode tree. Each node carries:

  • name — attribute key.
  • kind — one of scalar | media | relation | single-component | repeatable-component | dynamic-zone.
  • children — populated for single-component (recurses up to depth 5).
  • componentUIDs — populated for dynamic-zone.
  • disabled / disabledReason — set when the field is listed in the excludedFields config; the row is rendered but its checkbox is locked.

Skipped attributes (id, createdAt, updatedAt, publishedAt, createdBy, updatedBy, locale, localizations) are removed entirely — these are managed by Strapi.

The service also exposes buildPopulate(uid): a populate object suitable for strapi.documents().findOne(...) that pulls every relation/component/DZ to the depth the field tree expects. Dynamic zones use an explicit populate.on map per allowed component (not populate: '*'), so nested media/relations inside DZ blocks are fully fetched and not silently dropped on copy.

entries (server/src/services/entries.ts)

  • getEntries(uid, opts) — list view used by StepTargetEntries.
    • Filters: excludeId (omits the source document), search (substring match against title|name|handle|slug where present).
    • search matches the default chain plus the configured labelField(s) for the UID, so search hits the column the user actually sees.
    • Performs two parallel Strapi queries (drafts + count). For draft-and-publish-enabled types it issues a third query to mark which rows have a published counterpart, yielding a draft | published status. (No fuzzy modified state — a timestamp comparison is unreliable.)
    • Per-row label is resolved by deriveLabel(uid, entry) using the per-UID override map.
    • Response includes labelField so the admin can render the actual column header (e.g. HANDLE, KEY, TITLE) instead of a generic ENTRY.
  • getEntry(uid, documentId) — fetches one document with the full populate produced by schema.buildPopulate(uid). Used by both the field selector (preview values) and apply (source data).

apply (server/src/services/apply.ts)

Exposes three methods — preview, apply, restore — sharing a prepare step that loads the source, builds the schema map/index, strips config-excluded roots, and validates every remaining path against the live schema (throws on any unknown path, so the controller returns a 400 rather than writing garbage).

apply builds a per-target patch. Key rules:

  1. Scalar / media / relation fields are serialized via type-aware helpers (serializeRelation produces { set: [{ documentId }] }, etc.).
  2. Single-component — if the parent path is selected the entire component is replaced; if only sub-paths are selected the component is merged onto a stripped copy of the target's existing component.
  3. Repeatable component (whole field) replaces the target's entire array.
  4. Dynamic-zone selections use <field>[__component=<componentUID>]. The patch walks the target's blocks in order: blocks of other component types stay exactly where they are, and the source's blocks of the synced type are spliced in at the first position the type appeared on the target (or appended if it was absent). This preserves the overall block order instead of moving the synced type to the end. Selecting several component slices of the same zone in one apply composes (each is applied in turn, not clobbered).
  5. stripId is applied to component / DZ payloads because Strapi rejects nested id keys (media references are preserved as { id }).

Targets are processed sequentially, each in its own try/catch so one failure does not abort the rest. Before writing a target, the prior values of the affected root fields are snapshotted and returned so the change can be reverted. After the batch, a field-sync.applied event + structured log line are emitted for audit. Response shape: Array<{ documentId, status: 'success' | 'error', error?, snapshot? }>.

preview mirrors apply's setup but writes nothing — it returns, per target, { path, before, after } one-line previews powering the confirmation diff. restore writes a set of { documentId, data } snapshots back (the undo path).


Admin UI

FieldSyncPanel (admin/src/components/FieldSyncPanel.tsx)

Registered onto content-manager.apis.addEditViewSidePanel. Renders a single Sync Fields button that opens the modal. On mount it issues one GET /entries/:uid?pageSize=1&excludeId=... to determine whether sibling entries exist; if not, the button is disabled with a native title explanation. (Native title is used deliberately — the design-system Tooltip requires a TooltipProvider ancestor that is not present in the side-panel render context.)

StepTargetEntries

  • Sticky table header carrying the column label, "select all on page" checkbox, and a search input. The header lives outside the scrolling region so input focus is preserved across re-fetches and the search bar is always visible.
  • Rows-per-page selector (20 / 50 / 100, default 20) below the table, always visible; pagination controls sit alongside it when there's more than one page. Both are outside the scroller.
  • Each row shows the resolved label and a colour-coded status badge (draft → secondary, published → success).

StepFieldSelector

  • Renders the FieldNode tree returned by GET /schema/:uid, annotated client-side with current source values via buildFieldTree.
  • Single-component rows can be checked at the parent level (sync the whole component) or expanded to pick individual sub-fields. A parent shows indeterminate when only some of its children are checked.
  • Dynamic-zone rows show one sub-row per component type present in the source with a count. The parent DZ checkbox reflects whether all its child paths are checked and toggling it bulk-toggles them.
  • Select All recurses into single-component children so the UI shows every nested checkbox ticked even though the wire payload only needs the parent path. Excluded fields are skipped.
  • Excluded fields are rendered with cursor: not-allowed, dimmed opacity, disabled checkbox, and a title carrying the disabled reason.

StepConfirmation

On entry it fetches POST /preview and renders a per-target before → after diff so the overwrite is never applied blind. It warns when any selected field is empty on the source (syncing it clears the target) and, when "Published" is chosen, that the targets will go live. On apply it surfaces success / failure counts and per-row errors, and — because apply returns a snapshot of each target's prior values — offers a one-click Undo (POST /restore) until the dialog is closed.


Field path encoding

Paths are dot-separated. Components and dynamic zones use a dedicated encoding so the path remains a single string suitable for transport, sets, and serialization:

| Selection | Path | | --------- | ---- | | Scalar / media / relation field | seo.title (sub-field) or featured_product (root) | | Single-component as a whole | seo | | Specific sub-field of a single-component | seo.title, seo.open_graph.url | | Dynamic-zone, all blocks of one component | blocks[__component=blocks.breadcrumb] | | Repeatable component (whole field) | gallery (replaces the target's entire array) |

The regex ^(.+)\[__component=(.+)\]$ in apply.ts extracts (fieldName, componentUID) from a DZ-encoded path.

extractPaths (admin/src/utils/extractPaths.ts) turns the user's checked-set into the minimal wire representation: when the parent of a single-component is in the set, descendants are dropped to avoid redundant entries.


Configuration

Configure the plugin under config/plugins.js (installed from npm it is auto-discovered, so no resolve path is needed):

'field-sync': {
  enabled: true,
  config: {
    // Fields that should never be syncable (typically unique identifiers).
    // The wildcard '*' applies to every content type; per-UID arrays are
    // additive on top of the wildcard set.
    excludedFields: {
      '*': ['handle'],
      'api::page.page': ['slug'],
    },
    // Per-content-type label-column override for the target-entry picker.
    // Use when the default chain (title/name/handle/slug) resolves to a poor
    // label — e.g. a rich-text `title`. Value is a field name or an ordered
    // list of fallbacks. Omit for a generic install.
    labelFields: {
      'api::article.article': ['title', 'handle'],
      'api::cart.cart': 'handle',
    },
  },
},

| Option | Type | Default | Description | | ------ | ---- | ------- | ----------- | | excludedFields | Record<string, string[]> | { '*': ['handle'] } | Top-level attribute names that the plugin must surface but disable in the field selector and strip from any /apply payload. Keys are content-type UIDs or '*' for "all". | | labelFields | Record<string, string \| string[]> | {} | Per-content-type override for the target-picker label column. Value is a single field name or an ordered fallback list. When unset for a UID, the default chain title → name → handle → slug → documentId applies. |

Only top-level attribute names are honoured for excludedFields (e.g. handle, not seo.handle). Nested-attribute exclusion is intentionally not supported — keep the rule explicit and easy to audit.

All options are read at request time via strapi.plugin('field-sync') .config(...), so changes take effect on Strapi reload.

See examples/ for a ready-to-copy config/plugins.ts snippet.


Per-content-type label resolution

To produce a meaningful column in StepTargetEntries, the entries service looks up the active UID in the labelFields config map. The value is either a single field name or an ordered list of fallbacks:

labelFields: {
  'api::shade-finder-option.shade-finder-option': 'handle',
  'api::article.article': ['title', 'handle'],
},

If the override resolves to a non-string (or is absent) the service falls back to the chain title → name → handle → slug, then finally documentId. This is also the mechanism used to dodge content types whose title attribute is a CKEditor / blocks rich-text field (the value would be a serialised HTML/JSON blob, useless as a list label).

Add or edit entries in labelFields as new content types are introduced. The map is read at request time, so changes take effect on Strapi reload.


Build & development

The plugin is consumed from its built dist/ artifacts; Strapi loads dist/server/index.js and dist/admin/index.mjs (resolved via the package exports). The package is built with @strapi/sdk-plugin:

bun install        # install dev/peer deps
bun run build      # one-shot build → ./dist
bun run watch      # source-watching incremental build
bun run verify     # validate the plugin is publishable
bun test           # unit tests for the patch builder + path validation

When developing this plugin against a host Strapi app, link it (e.g. bun run watch:link in a host that uses @strapi/sdk-plugin, or a workspace link) so the host picks up changes. After non-trivial server changes, restart Strapi to ensure the new module graph is loaded.


Limitations & known caveats

  • Same-content-type only. Cross-type sync (e.g. push from page to homepage) is out of scope; the assumed shape match is what makes the patch builder safe.
  • Repeatable components sync whole-field only — selecting one replaces the target's entire array. Item-level merge is intentionally omitted (picking "item 2" would otherwise silently delete every other item). Dynamic zones sync per component-type slice, preserving the ordering of unaffected types.
  • Undo is session-scoped. apply snapshots each target's prior values and the result screen offers a one-click Undo, but the snapshot lives in the open dialog only — once closed, revert manually. (The apply itself is recorded via the field-sync.applied event + a structured log line for durable audit.) Undo of a published sync also reverts the published version (republish or unpublish as appropriate); it assumes draft and published were in sync before the apply — the realistic case for a published entry.
  • Custom fields sync as their stored value. A custom field (e.g. a CKEditor rich-text field) is reported by its underlying type (string/json/…) and appears in the selector as a scalar; its value copies as-is. Custom fields backed by an exotic value shape are still copied verbatim — no special diff/merge is attempted.
  • Localized variants are not handled. The plugin operates on the entry as returned by the Documents API in the current draft locale; cross-locale propagation is not attempted.
  • Apply runs synchronously, sequentially, inside one HTTP request. That's fine for the deliberate, checkbox-selected target sets the UI produces (tens, low hundreds). Very large batches (thousands) belong in a migration script, not this admin flow — they'd need batching + progress, which is out of scope.