Migrate from Contentstack to Payload
A working reference for moving a Contentstack stack onto Payload: a field-by-field mapping table, how modular blocks and global fields translate, and the export-and-import pipeline that runs in practice.
Payload Partner · Top Contributor We build and maintain Payload plugins used by the wider community. Direct access to the maintainers when something needs attention.A Contentstack migration is mostly a structural translation. The content-type schema maps onto Payload collections cleanly, and the work concentrates in three Contentstack-specific constructs: modular blocks, global fields, and the JSON rich text editor. WAYF has built this migration in production — the Ingersoll Rand frontend ran on Contentstack, and we decoupled it behind a content-provider contract with adapters for both Contentstack and Payload, so the mapping below is drawn from a real move rather than a spec read.
WAYF is a Payload Partner agency and a top contributor to its open source.
Field mapping
Each Contentstack content type becomes a Payload collection, each field a Payload field. The table covers Contentstack’s field set.
| Contentstack field | Payload field | Notes |
|---|---|---|
| Single line textbox | text | Direct |
| Multi line textbox | textarea | Direct |
| Rich text editor (JSON RTE) | richText (Lexical) | Tree transform — see below |
| Markdown | richText | Convert the Markdown body |
| HTML / custom | richText | HTML-to-Lexical import |
| Number | number | Direct |
| Boolean | checkbox | Direct |
| Date | date | ISO in; check timezone handling |
| File (asset) | upload | Migrate assets first |
| Link | group (title, href) | Contentstack stores a title + URL pair |
| Reference | relationship | Carries uid + content type — two-pass |
| Group | group | Nested field group |
| Group (multiple) | array | One row per instance |
| Global field | reusable group | Define the field set once, share it |
| Modular blocks | blocks | The natural fit — see below |
| Select (dropdown) | select | Carry the choice list into options |
| JSON | json | Direct |
The rows that carry the work are references, assets, modular blocks, and global fields.
Modular blocks and global fields
Two Contentstack constructs need a deliberate mapping decision before any data moves.
Modular blocks are Contentstack’s page-builder field: an ordered list where an editor stacks sections of different shapes. Each entry names its block type. This is the same shape as Payload’s blocks field — define one Payload block per Contentstack block type, mirror the subfields, and the data migrates straight across. On a marketing stack where most pages are modular-block compositions, building these block definitions is the bulk of the schema work.
Global fields are reusable field groups shared across content types — an SEO group reused on every page type, say. Model the global field once in Payload, as a shared field array imported wherever the content type used it, so the reuse survives the move instead of being copy-pasted into each collection.
References and the content type
A Contentstack reference field stores an array of links, each carrying the referenced entry’s uid and its _content_type_uid:
"author": [{ "uid": "blt9f2...", "_content_type_uid": "author" }]
The uid is a Contentstack identifier; Payload assigns its own document ID at creation. So references resolve on a second pass, after every entry exists. The _content_type_uid tells you which Payload collection the relationship points at, which matters when a reference field can target more than one type — map it to a Payload relationship with multiple relationTo collections.
How the migration runs
Export the stack
Two routes:
- Contentstack CLI (
csdx cm:stacks:export) writes content types, global fields, entries, assets, environments, and locales to a local folder. Good for a full, repeatable export. - Content Management API (CMA) when reading a live stack. Page through each content type’s entries, including references and embedded assets:
async function* readEntries(contentType: string) {
let skip = 0;
for (;;) {
const res = await fetch(
`https://api.contentstack.io/v3/content_types/${contentType}/entries` +
`?include_count=true&limit=100&skip=${skip}`,
{ headers: { api_key: API_KEY, authorization: MANAGEMENT_TOKEN } },
);
const { entries, count } = await res.json();
yield* entries;
skip += entries.length;
if (skip >= count) break;
}
}
Migrate assets first
Reference and file fields point at assets, so assets exist before entries. Pull each asset’s binary, create a Payload upload, and record the ID map:
const buffer = Buffer.from(await (await fetch(asset.url)).arrayBuffer());
const created = await payload.create({
collection: "media",
data: { alt: asset.title ?? "" },
file: { data: buffer, name: asset.filename, mimetype: asset.content_type, size: buffer.byteLength },
});
assetIdMap.set(asset.uid, created.id);
Import entries, then resolve references
Pass one creates entries with reference fields blank and records contentstackUid → payloadId. Modular blocks and groups travel inline with the entry, but any reference or asset inside a block still needs remapping — assets on pass one via the asset map, references on pass two. Pass two updates each entry, swapping stored uids for Payload IDs.
Rich text: JSON RTE or HTML
Contentstack’s JSON Rich Text Editor stores a tree, close in spirit to Lexical but with its own node names. Walk the JSON RTE tree and emit Lexical: p → paragraph, h1–h6 → heading, ol/ul → list, blockquote → quote, a → link, img and embedded reference nodes → upload or block nodes resolved through the asset and entry maps. Text styling is a set of boolean attributes (bold, italic, underline, strikethrough, inlineCode) on each text node, which become Lexical’s format bitmask:
const FORMAT = { bold: 1, italic: 2, strikethrough: 4, underline: 8, inlineCode: 16 } as const;
Stacks on the older HTML-based RTE need an HTML-to-Lexical import instead. Either way, create one document by hand in the Payload admin and read its stored richText value to confirm the exact node shape your transformer must produce — it can shift between Payload versions.
Localization
Contentstack keeps a master locale plus additional locales, with each entry localized per locale and an unset field falling back to master. Payload keeps locale variants under one document ID, selected by a locale argument on each write. Fetch each entry per locale (?locale=fr-fr), import the master locale first to create the Payload document, then update the same document for each additional locale. Map the locale codes up front; Contentstack’s fr-fr is rarely the same string as your Payload locale identifier.
Environments and publishing
Contentstack publishes entries to environments — separate targets such as development, staging, and production, each with its own published version and publish_details. Decide which environment is the source of truth for the migration, usually production, and migrate that published content. Map the publish state onto Payload’s drafts via its versions feature, so an unpublished entry lands as a draft rather than going live on import.
What it costs
A stack with a handful of content types, a few global fields, no deep modular-block nesting, and a single locale is three to five engineering days: schema derivation, the export, the asset pipeline, and a cutover rehearsal.
A large marketing stack — many modular-block types, nested groups, references across several content types, the JSON RTE with embedded entries, and multiple locales — is a three-to-four-week migration, with the block definitions and the RTE transform as the long poles. We sized exactly this shape on the Ingersoll Rand frontend. A scoped discovery is the place to size yours.
If you want an honest read on what moving your specific Contentstack stack involves, a 25-minute call is the fastest way to get one.
We're booking content platform
engagements for 2026.
Twenty-five minutes to walk through the work and decide if we're the right team for it. Scoping and a fixed price come after.