@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-syncThe 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
- High-level flow
- Architecture
- HTTP routes
- Security & permissions
- Server services
- Admin UI
- Field path encoding
- Configuration
- Per-content-type label resolution
- Build & development
- 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 + writerThe 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) requireplugin::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 ofscalar | media | relation | single-component | repeatable-component | dynamic-zone.children— populated forsingle-component(recurses up to depth 5).componentUIDs— populated fordynamic-zone.disabled/disabledReason— set when the field is listed in theexcludedFieldsconfig; 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 byStepTargetEntries.- Filters:
excludeId(omits the source document),search(substring match againsttitle|name|handle|slugwhere present). searchmatches the default chain plus the configuredlabelField(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 | publishedstatus. (No fuzzymodifiedstate — a timestamp comparison is unreliable.) - Per-row label is resolved by
deriveLabel(uid, entry)using the per-UID override map. - Response includes
labelFieldso the admin can render the actual column header (e.g.HANDLE,KEY,TITLE) instead of a genericENTRY.
- Filters:
getEntry(uid, documentId)— fetches one document with the full populate produced byschema.buildPopulate(uid). Used by both the field selector (preview values) andapply(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:
- Scalar / media / relation fields are serialized via type-aware helpers
(
serializeRelationproduces{ set: [{ documentId }] }, etc.). - 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.
- Repeatable component (whole field) replaces the target's entire array.
- 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). stripIdis applied to component / DZ payloads because Strapi rejects nestedidkeys (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
FieldNodetree returned byGET /schema/:uid, annotated client-side with current source values viabuildFieldTree. - 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 Allrecurses 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 atitlecarrying 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 validationWhen 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
pagetohomepage) 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.
applysnapshots 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 thefield-sync.appliedevent + a structured log line for durable audit.) Undo of apublishedsync 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.
