Now booking enterprise content platform builds for 2026. Contact us

CMS migrations Strapi

Migrate from Strapi to Payload

A working reference for moving a Strapi project onto Payload: a field-by-field mapping table, how components and dynamic zones translate, and the API behaviour that trips up every first migration script.

  • From Strapi
  • To Payload
Payload Partner · Top Contributor We build and maintain Payload plugins used by the wider community. Direct access to the maintainers when something needs attention.

Strapi and Payload are close cousins: both are code-first, self-hosted, Node-based headless CMSs that generate a typed API from a schema you keep in version control. That makes most of a Strapi migration mechanical. The work concentrates in two places Strapi models differently from anything else — components and dynamic zones — and in one API behaviour that breaks the first script everyone writes.

WAYF is a Payload Partner agency and a top contributor to its open source. The mechanics below are how the migration runs in practice. They assume Strapi v4 or v5; the differences that matter are called out where they land.

Field mapping

Each Strapi content type becomes a Payload collection, each attribute a Payload field. The table covers Strapi’s attribute types.

Strapi typePayload fieldNotes
Text (short)textDirect
Text (long)textareaDirect
Rich text (Markdown)richText (Lexical)v4 default; Markdown body needs a converter
BlocksrichText (Lexical)v5 structured editor; tree transform — see below
Numbernumberinteger, decimal, float, biginteger all map here
BooleancheckboxDirect
Date / DateTime / TimedateUse Payload’s date with the matching admin.date config
EmailemailBuilt-in field with validation
EnumerationselectCarry the value list into options
UIDtextMark unique: true; wire up a slug hook if needed
JSONjsonDirect
Media (single)uploadMigrate files first — see below
Media (multiple)upload, hasMany: trueSame ID map
RelationrelationshipTwo-pass import; mind the populate problem
Component (single)groupReusable field group → grouped fields
Component (repeatable)arrayOne row per component instance
Dynamic zoneblocksThe natural fit — see below
PasswordNot migrated; re-issue via Payload auth

The rows that carry the project are relations, media, components, and dynamic zones. The rest is a value copy.

Components and dynamic zones

This is what makes a Strapi migration different from a Contentful or WordPress one.

A component is a reusable group of fields. In the API it appears inline — a nested object for a single component, an array of objects for a repeatable one. Map a single component to a Payload group and a repeatable component to a Payload array whose subfields mirror the component’s attributes. The shapes line up directly.

A dynamic zone is an ordered list of mixed component types — the classic page-builder field, where an editor stacks a hero, then a gallery, then a quote, in any order. Each entry in the array carries a __component key naming its type:

"blocks": [
  { "__component": "sections.hero", "title": "...", "image": { } },
  { "__component": "sections.quote", "body": "...", "author": "..." }
]

Payload’s blocks field is built for exactly this. Each Strapi component type becomes a Payload block, and the __component string maps to the block’s blockType:

function mapDynamicZone(zone: StrapiComponent[]): PayloadBlock[] {
  return zone.map((entry) => {
    const { __component, id, ...fields } = entry;
    return {
      blockType: BLOCK_TYPE_MAP[__component], // "sections.hero" → "hero"
      ...transformFields(fields),
    };
  });
}

Define one Payload block per Strapi component used in a dynamic zone, with matching subfields. Once that mapping exists, the dynamic-zone data migrates cleanly. Building the block definitions is the bulk of the schema work on a page-builder-heavy Strapi project.

The populate problem

Here is the behaviour that breaks the first script. By default, the Strapi REST API returns only an entry’s own scalar fields. Relations, media, components, and dynamic zones come back empty unless you ask for them explicitly with populate. A naive GET /api/articles looks like it is returning complete data while silently dropping every relationship and image.

You have to populate every nested path you intend to migrate:

GET /api/articles?populate[author][populate]=*&populate[cover]=*&populate[blocks][populate]=*

Dynamic zones make this sharper: each component inside the zone needs its own nested media and relations populated, so the populate object gets deep. On Strapi v4 the populate=deep plugin or an explicit populate object handles it; on v5 you write the populate object out by path. Confirm the populated response actually contains the nested data before you migrate a single record — an unpopulated field reads as “no data” and you will not get an error.

How the migration runs

Export the content

Two routes, depending on access:

  • Data Transfer (strapi export, v4.6+) writes a .tar.gz containing entities, relation links, assets, schema, and config. Good when you control the Strapi host and want everything in one archive.
  • REST or GraphQL API with populate, when you are reading a running instance. Page through every collection:
async function* readAll(collection: string) {
  let page = 1;
  for (;;) {
    const res = await fetch(
      `${STRAPI_URL}/api/${collection}` +
        `?populate=*&pagination[page]=${page}&pagination[pageSize]=100`,
      { headers: { Authorization: `Bearer ${STRAPI_TOKEN}` } },
    );
    const { data, meta } = await res.json();
    yield* data;
    if (page >= meta.pagination.pageCount) break;
    page++;
  }
}

Migrate media first

Strapi’s upload plugin stores file metadata in the database and the binaries on a provider — local public/uploads, S3, Cloudinary. Pull each file and create a Payload upload, recording the ID for later reference:

const fileUrl = media.url.startsWith("http")
  ? media.url
  : `${STRAPI_URL}${media.url}`;
const buffer = Buffer.from(await (await fetch(fileUrl)).arrayBuffer());

const created = await payload.create({
  collection: "media",
  data: { alt: media.alternativeText ?? "" },
  file: {
    data: buffer,
    name: media.name,
    mimetype: media.mime,
    size: buffer.byteLength,
  },
});

mediaIdMap.set(media.id, created.id);

Import entries, then resolve relations

Strapi relations carry the related entry’s numeric id (and in v5, its documentId). Payload assigns its own IDs at creation, so relationships resolve on a second pass, the same two-pass approach a Contentful migration needs:

  1. Create every entry with relation fields blank, recording strapiId → payloadId.
  2. Update each entry, mapping stored Strapi IDs to Payload IDs.

Components and dynamic zones travel inline with their parent entry on pass one, but any relation or media reference inside a component still has to be remapped — media via the media map on pass one, entry relations on pass two.

Rich text: Markdown or Blocks

Which transform you write depends on the Strapi version.

  • Strapi v4 — Markdown. The default rich text field stores raw Markdown. Parse it to an AST (unified / remark) and emit Lexical nodes, or convert Markdown to HTML and run an HTML-to-Lexical importer. Either way it is a content transform, not a field-by-field copy.
  • Strapi v5 — Blocks. The Blocks editor stores a structured JSON array. Node types — paragraph, heading (with level), list (ordered/unordered), quote, code, image, link — map onto Lexical’s equivalents. Text styling is a set of booleans on each text node (bold, italic, underline, strikethrough, code), which become Lexical’s format bitmask:
const FORMAT = { bold: 1, italic: 2, strikethrough: 4, underline: 8, code: 16 } as const;

function textFormat(node: StrapiTextNode): number {
  return (Object.keys(FORMAT) as (keyof typeof FORMAT)[])
    .reduce((acc, k) => (node[k] ? acc | FORMAT[k] : acc), 0);
}

Inline images in the Blocks format reference a media entry, so they go through the media map. As with any Lexical target, create one document by hand in the Payload admin and read its stored richText value to get the exact node shape your transformer must emit; the shape can shift between Payload versions.

Localization

Strapi’s i18n plugin keeps each locale as a separate entry. In v4 the locale versions are tied together through a localizations relation; in v5 they share a documentId and differ by locale. Payload keeps locale variants under one document ID, selected by a locale argument on each write.

Migrate the default locale first to establish the Payload document, then update the same document for each additional locale:

const doc = await payload.create({ collection: "posts", data: en, locale: "en" });
await payload.update({ collection: "posts", id: doc.id, data: pl, locale: "pl" });

Group the Strapi entries by their shared document (via localizations on v4 or documentId on v5) before importing, so each locale set resolves to a single Payload document. Map the locale codes up front; Strapi’s and Payload’s identifiers do not always match.

Draft and publish

Strapi’s Draft & Publish marks an entry’s state with publishedAt — a timestamp when published, null when draft. Payload models this with drafts through its versions feature. Enable versions.drafts on the collection and set each migrated document’s status from publishedAt, so a draft in Strapi lands as a draft in Payload rather than going live the moment it is imported.

In Strapi v5 this interacts with documentId: a single document can hold both a draft and a published variant. Decide per collection whether you migrate the published variant, the draft, or both, and make that call before the import runs.

What it costs

A v5 project with a handful of content types, a few components, no dynamic zones, and a single locale is three to five engineering days: deriving the Payload schema, the populate-aware export, the media pipeline, and a cutover rehearsal.

A page-builder-heavy project — several dynamic zones, dozens of component types, nested components with their own media and relations, multiple locales, and a v4 Markdown body to convert — is a three-to-four-week migration. The block definitions and the populate paths are the long poles. If that is your project, a scoped discovery is the place to size it.

If you want an honest read on what moving your specific Strapi project involves, a 25-minute call is the fastest way to get one.


Rather have it done for you? WAYF runs Strapi to Payload migrations end to end.

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.