BuildWithMatija
Get In Touch
  1. Home
  2. Blog
  3. Payload
  4. Complete Strapi to Payload CMS Migration Guide — 7 Steps

Complete Strapi to Payload CMS Migration Guide — 7 Steps

Migrate Strapi v4/v5 to Payload CMS: schema mapping, Slate→Lexical rich text conversion, ID remapping, and admin tips.

5th April 2026·Updated on:14th March 2026·MŽMatija Žiberna·
Payload
Complete Strapi to Payload CMS Migration Guide — 7 Steps

Need Help Making the Switch?

Moving to Next.js and Payload CMS? I offer advisory support on an hourly basis.

Book Hourly Advisory

If you landed here after hitting a wall with the Strapi v4 to v5 upgrade, you're not alone. The Entity Service deprecation, the documentId rewrite, plugin breakage after the build tooling switch to Vite — for a lot of teams, the upgrade became the moment they started seriously asking whether to finish it or use the disruption as a reason to move.

Strapi and Payload are the two closest tools in the headless CMS space. Both are Node.js. Both are self-hosted. Both organise content into collections. The migration is structurally simpler than anything involving WordPress or Contentful because you're not crossing architectural paradigms — you're translating between two systems that share the same DNA.

This guide covers the migration end-to-end: content model mapping, schema rebuild in TypeScript, data export and import, rich text conversion from Slate to Lexical, ID remapping, and admin customisation differences. There's also an honest section at the end on when the migration isn't worth doing. If you're still evaluating whether to switch at all, start with the Payload CMS vs Strapi comparison first.


Mapping Your Strapi Content Model to Payload

The good news is that Strapi and Payload use the same fundamental content primitives. The translation is mostly 1:1.

StrapiPayload
Collection TypeCollection
Single TypeGlobal
Component (repeatable)Array of Blocks
Component (non-repeatable)Named Group field
Dynamic ZoneBlocks field (polymorphic)
RelationRelationship field
MediaUpload collection

The one area that requires judgment is the component-to-Blocks translation. Repeatable components that drive variable page layouts — the kind where editors stack and reorder sections — map cleanly to Payload's Blocks field. Components that are structurally fixed and always present (SEO metadata, address objects) are better modelled as named groups or inline embedded objects. Make this decision before touching any code; it affects how your admin UI looks and how editors work with content.

Single Types in Strapi become Globals in Payload. The concept is identical: a single document per type, used for site-wide settings, navigation, or homepage content. The translation is mechanical.


Recreating Your Strapi Schema as Payload Collections

Let's work through a concrete example: an articles collection with a tags array, an author relationship to a users collection, and a rich text body. Here's what that looks like in Strapi's content-type JSON:

// File: src/api/article/content-types/article/schema.json
{
  "kind": "collectionType",
  "collectionName": "articles",
  "attributes": {
    "title": { "type": "string", "required": true },
    "body": { "type": "richtext" },
    "tags": { "type": "json" },
    "author": {
      "type": "relation",
      "relation": "manyToOne",
      "target": "plugin::users-permissions.user"
    }
  }
}

In Payload, the same collection is a TypeScript file in your codebase:

// File: src/collections/Articles.ts
import type { CollectionConfig } from 'payload'

export const Articles: CollectionConfig = {
  slug: 'articles',
  fields: [
    {
      name: 'title',
      type: 'text',
      required: true,
    },
    {
      name: 'body',
      type: 'richText',
    },
    {
      name: 'tags',
      type: 'array',
      fields: [
        {
          name: 'tag',
          type: 'text',
        },
      ],
    },
    {
      name: 'author',
      type: 'relationship',
      relationTo: 'users',
      hasMany: false,
    },
  ],
}

The translation is mostly mechanical. Field types map cleanly: string becomes text, richtext becomes richText, relations become relationship fields pointing to the target collection's slug.

One decision to make early: when using the PostgreSQL adapter, the idType option controls whether Payload uses serial integers or UUIDs as primary keys.

// File: payload.config.ts
import { postgresAdapter } from '@payloadcms/db-postgres'

export default buildConfig({
  db: postgresAdapter({
    pool: { connectionString: process.env.DATABASE_URI },
    idType: 'uuid', // or 'serial' — this affects the ID remapping step
  }),
  // ...
})

If you choose uuid, your Payload IDs will be UUIDs from the start, which makes the remapping table (covered in the relationships section) slightly cleaner to manage. If you stay with serial, IDs are auto-increment integers — the same format Strapi v4 used, which can reduce confusion during migration but makes cross-system ID matching trickier.

Once your collections are defined, follow the production schema migration workflow before running any import scripts — push: false and migration files only in production.


Exporting from Strapi and Importing into Payload

Step 1 — Export from Strapi

Strapi's Data Management feature (available from v4.6.0+) provides a CLI export command that produces a compressed archive of your content:

strapi export --no-encrypt --no-compress -f strapi-export

The --no-encrypt --no-compress flags give you an unencrypted tar you can inspect directly. Unpack it and you'll find:

strapi-export/
  entities/
    entities_00001.jsonl   # one JSON object per line, one file per chunk
    entities_00002.jsonl
  links/
    links_00001.jsonl      # relationship data
  schemas/
    schemas.jsonl          # your content type definitions
  metadata.json

Each line in the entity files is a JSON object representing one document. In Strapi v4, the identifier is a numeric id. In Strapi v5, you'll also find a documentId field — a stable string identifier introduced with the Document Service API.

Step 2 — Transform and Import

Here's a TypeScript script that reads the JSONL export, transforms the article documents, and imports them into Payload via the Local API:

// File: scripts/import-articles.ts
import payload from 'payload'
import config from '../payload.config'
import { createReadStream } from 'fs'
import { createInterface } from 'readline'
import path from 'path'

// Maps Strapi numeric IDs to newly created Payload IDs
export const articleIdMap = new Map<number, string>()

async function importArticles() {
  await payload.init({ config })

  const exportPath = path.resolve('./strapi-export/entities/entities_00001.jsonl')
  const rl = createInterface({
    input: createReadStream(exportPath),
    crlfDelay: Infinity,
  })

  for await (const line of rl) {
    const record = JSON.parse(line)

    // Filter to the articles collection only
    if (record.__type !== 'api::article.article') continue

    const doc = await payload.create({
      collection: 'articles',
      data: {
        title: record.title,
        body: record.body, // rich text handled separately — see next section
        tags: (record.tags ?? []).map((t: string) => ({ tag: t })),
        // relationships resolved in second pass
      },
    })

    // Store the mapping: Strapi ID → Payload ID
    articleIdMap.set(record.id, doc.id as string)
    console.log(`Imported article ${record.id} → ${doc.id}`)
  }

  console.log(`Done. Imported ${articleIdMap.size} articles.`)
  process.exit(0)
}

importArticles()

This script uses Payload's Local API (payload.create), which runs directly in Node without an HTTP layer. For CLI-based imports against a running Payload instance, the @payloadcms/sdk is the cleaner option — it handles auth and gives you the same find, create, update, and delete methods over HTTP.

Relationships are intentionally left out of the first pass. They're handled separately in the ID remapping step after all documents exist.


Converting Strapi's Rich Text to Payload's Lexical Format

This is the most technically involved part of the migration, and the part that every other guide skips entirely.

Strapi v4 and v5 both ship a Slate-based rich text editor as the default. Payload 3.x uses Lexical (@payloadcms/richtext-lexical). The node schemas are different, and data stored in one format cannot be read directly by the other.

Here's the structural difference for a heading node. In Strapi's Slate output:

{
  "type": "heading",
  "level": 2,
  "children": [{ "text": "Section title" }]
}

In Payload's Lexical format (SerializedHeadingNode):

{
  "type": "heading",
  "tag": "h2",
  "children": [{ "type": "text", "text": "Section title", "version": 1 }],
  "direction": "ltr",
  "format": "",
  "indent": 0,
  "version": 1
}

The structure is similar enough to convert programmatically. Here's a converter that handles the common node types:

// File: scripts/slate-to-lexical.ts

type SlateNode = {
  type?: string
  level?: number
  url?: string
  text?: string
  bold?: boolean
  italic?: boolean
  underline?: boolean
  children?: SlateNode[]
}

type LexicalNode = Record<string, unknown>

export function convertSlateToLexical(slateNodes: SlateNode[]): LexicalNode {
  return {
    root: {
      type: 'root',
      children: slateNodes.map(convertNode),
      direction: 'ltr',
      format: '',
      indent: 0,
      version: 1,
    },
  }
}

function convertNode(node: SlateNode): LexicalNode {
  // Text leaf node
  if (node.text !== undefined) {
    return {
      type: 'text',
      text: node.text,
      format: getTextFormat(node),
      version: 1,
    }
  }

  const children = (node.children ?? []).map(convertNode)

  switch (node.type) {
    case 'paragraph':
      return { type: 'paragraph', children, direction: 'ltr', format: '', indent: 0, version: 1 }

    case 'heading':
      return {
        type: 'heading',
        tag: `h${node.level ?? 2}`,
        children,
        direction: 'ltr',
        format: '',
        indent: 0,
        version: 1,
      }

    case 'list':
      return {
        type: 'list',
        listType: 'bullet',
        children,
        direction: 'ltr',
        format: '',
        indent: 0,
        version: 1,
        start: 1,
        tag: 'ul',
      }

    case 'list-item':
      return {
        type: 'listitem',
        children,
        direction: 'ltr',
        format: '',
        indent: 0,
        version: 1,
        value: 1,
      }

    case 'link':
      return {
        type: 'link',
        url: node.url ?? '',
        children,
        direction: 'ltr',
        format: '',
        indent: 0,
        version: 1,
        fields: { url: node.url ?? '', newTab: false },
        rel: 'noreferrer',
        target: null,
        title: null,
      }

    default:
      // Fall back to paragraph for unknown types
      return { type: 'paragraph', children, direction: 'ltr', format: '', indent: 0, version: 1 }
  }
}

function getTextFormat(node: SlateNode): number {
  let format = 0
  if (node.bold) format |= 1
  if (node.italic) format |= 2
  if (node.underline) format |= 8
  return format
}

Payload 3.79.0 upgraded the internal Lexical dependency from 0.35.0 to 0.41.0. The release notes confirm all breaking changes are handled internally — no action required for standard usage. Custom Lexical node converters should still be validated after a Payload version upgrade.

Use the converter in the import script by replacing the body field:

// In import-articles.ts
import { convertSlateToLexical } from './slate-to-lexical'

// Inside the import loop:
data: {
  title: record.title,
  body: record.body ? convertSlateToLexical(record.body) : undefined,
  // ...
}

Test a handful of documents manually before running the full import. Edge cases like nested lists or Strapi-specific custom blocks will need their own case branches added to the converter.


Remapping Strapi IDs to Payload IDs

Strapi v4 uses auto-increment integers as primary keys. Strapi v5 adds documentId — a stable string — but the underlying id is still a numeric integer. Payload's PostgreSQL adapter uses either serial integers or UUIDs depending on your idType config. MongoDB Payload stores stringified ObjectIds.

The problem: you can't insert a Strapi ID into a Payload relationship field and expect it to resolve. The ID formats are incompatible, and even if they happened to match numerically, Payload's relationship fields store Payload document IDs.

The solution is a two-pass import. Pass one creates all documents (articles, authors, tags — any collection involved in a relationship) and builds a mapping table. Pass two resolves relationships using that table.

Here's the second pass for the articles collection, linking the author relationship:

// File: scripts/import-article-relations.ts
import payload from 'payload'
import config from '../payload.config'
import { articleIdMap } from './import-articles'
import { authorIdMap } from './import-authors' // built in a separate first-pass script
import { createReadStream } from 'fs'
import { createInterface } from 'readline'
import path from 'path'

async function importArticleRelations() {
  await payload.init({ config })

  const linksPath = path.resolve('./strapi-export/links/links_00001.jsonl')
  const rl = createInterface({
    input: createReadStream(linksPath),
    crlfDelay: Infinity,
  })

  for await (const line of rl) {
    const link = JSON.parse(line)

    // Filter to article → author relations only
    if (
      link.__type !== 'api::article.article' ||
      link.field !== 'author'
    ) continue

    const payloadArticleId = articleIdMap.get(link.entity_id)
    const payloadAuthorId = authorIdMap.get(link.related_id)

    if (!payloadArticleId || !payloadAuthorId) continue

    await payload.update({
      collection: 'articles',
      id: payloadArticleId,
      data: {
        author: payloadAuthorId,
      },
    })
  }

  console.log('Relations resolved.')
  process.exit(0)
}

importArticleRelations()

Run all first-pass scripts (one per collection) before running any second-pass relationship scripts. The order matters: a relationship target must exist in Payload before you can reference it.

Export your articleIdMap, authorIdMap, and any other mapping tables to JSON files on disk between passes. If a second-pass script fails midway, you won't need to re-run the full first pass to recover the mapping state.


What Happens to Your Strapi Admin Customisations?

If your team has built Strapi admin customisations, the mental model shifts significantly.

In Strapi, admin customisation uses named injection zones: you register components via bootstrap and register hooks in src/admin/app.tsx, targeting specific named zones in the admin UI (contentManager.editView.informations, for example). The layout is fixed; you're injecting into predefined slots.

In Payload, customisation is declared directly in the collection config via the admin.components field. You own the component slots — header, beforeList, afterList, Description, and others — and render your own React components there. There's no injection zone API to learn; it's just React props in TypeScript.

// File: src/collections/Articles.ts (admin customisation)
export const Articles: CollectionConfig = {
  slug: 'articles',
  admin: {
    components: {
      beforeList: [() => import('./components/ArticleStats')],
      Description: () => import('./components/ArticleDescription'),
    },
  },
  fields: [...],
}

If you've built Strapi plugins using @strapi/sdk-plugin — particularly anything that hooks into plugin content type lifecycles — those will need a complete rewrite as Payload hooks or custom admin components. The good news is the resulting code is simpler: everything lives in your repository, typed, no plugin registration ceremony.

For a full walkthrough of what's available in Payload's admin component system, the Payload CMS Admin UI custom components guide covers the patterns in detail.


When the Migration Isn't Worth It

This section exists because not every Strapi project should move to Payload. Three situations where the ROI isn't there:

Your content team manages their own schema. Strapi's visual content type builder lets editors and non-technical team members add fields, create new content types, and modify schemas without touching code or triggering a deployment. Payload removes this entirely — every schema change is a TypeScript edit and a code deploy. If your organisation has editors managing their own content model, this isn't a DX improvement, it's a regression.

You're running three or more Strapi plugins for core functionality. Strapi's plugin ecosystem has over 400 community packages. Payload's is curated and growing, but materially smaller. If your project depends on Strapi plugins for search integration, payment processing, localisation workflows, or enterprise SSO, check whether Payload has equivalents before starting the migration. In some cases you'll be rebuilding features, not migrating content.

Your team doesn't work in TypeScript. Payload's configuration, schema definitions, access control, and hooks are all TypeScript throughout. There's no escape hatch. A team without TypeScript fluency will struggle to maintain a Payload codebase and won't get the benefits that make the migration worthwhile in the first place.

If none of these apply — you have an engineering-led team, a manageable plugin footprint, and TypeScript as a baseline — the migration is straightforward and the long-term DX improvement is real. For a broader view of what Payload offers compared to the headless CMS landscape, the best headless CMS for Next.js guide is worth reading before committing.


Ready to Migrate?

This guide covered the full migration path: content model mapping, TypeScript schema rebuild, Strapi export and Payload import, Slate to Lexical rich text conversion, ID remapping with a two-pass approach, and admin customisation differences.

If your team would rather hand the migration off than run it in-house, the Payload CMS migration service covers end-to-end migrations from Strapi, Contentful, WordPress, and Sanity.

Let me know in the comments if you run into edge cases not covered here — particularly around Strapi dynamic zones or custom field types — and subscribe for more practical Payload guides.

Thanks, Matija

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

Stay updated! Get our weekly digest with the latest learnings on NextJS, React, AI, and web development tips delivered straight to your inbox.

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Table of Contents

  • Mapping Your Strapi Content Model to Payload
  • Recreating Your Strapi Schema as Payload Collections
  • Exporting from Strapi and Importing into Payload
  • Step 1 — Export from Strapi
  • Step 2 — Transform and Import
  • Converting Strapi's Rich Text to Payload's Lexical Format
  • Remapping Strapi IDs to Payload IDs
  • What Happens to Your Strapi Admin Customisations?
  • When the Migration Isn't Worth It
  • Ready to Migrate?
On this page:
  • Mapping Your Strapi Content Model to Payload
  • Recreating Your Strapi Schema as Payload Collections
  • Exporting from Strapi and Importing into Payload
  • Converting Strapi's Rich Text to Payload's Lexical Format
  • Remapping Strapi IDs to Payload IDs
Build With Matija Logo

Build with Matija

Matija Žiberna

I turn scattered business knowledge into one usable system. End-to-end system architecture, AI integration, and development.

Quick Links

Projects
  • How I Work
  • Blog
  • RSS Feed
  • Services

    • Payload CMS Websites
    • Bespoke AI Applications
    • Advisory

    Payload

    • Payload CMS Websites
    • Payload CMS Developer
    • Audit
    • Migration
    • Pricing
    • Payload vs Sanity
    • Payload vs WordPress
    • Payload vs Strapi
    • Payload vs Contentful

    Industries

    • Manufacturing
    • Construction

    Get in Touch

    Have a project in mind? Let's discuss how we can help your business grow.

    Book a discovery callContact me →
    © 2026BuildWithMatija•Principal-led system architecture•All rights reserved