---
title: "Complete Strapi to Payload CMS Migration Guide — 7 Steps"
slug: "strapi-to-payload-cms-migration-guide"
published: "2026-04-05"
updated: "2026-04-06"
validated: "2026-03-14"
categories:
  - "Payload"
tags:
  - "Strapi to Payload CMS migration"
  - "Strapi to Payload migration"
  - "Payload CMS migration guide"
  - "Slate to Lexical conversion"
  - "rich text conversion Payload"
  - "ID remapping Strapi Payload"
  - "TypeScript Payload collections"
  - "Strapi export import"
  - "two-pass import relationships"
  - "Payload admin customization"
  - "content model mapping"
  - "Postgres idType uuid serial"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "strapi@4"
  - "strapi@5"
  - "payload@latest"
  - "typescript@5"
  - "node@20"
  - "postgres@15"
  - "vite@5"
status: "stable"
llm-purpose: "Strapi to Payload CMS migration: step-by-step guide to map schemas, convert Slate to Lexical rich text, remap IDs, and import data. Start migrating today."
llm-prereqs:
  - "Access to Strapi v4"
  - "Access to Strapi v5"
  - "Access to Payload CMS"
  - "Access to TypeScript"
  - "Access to Node.js"
llm-outputs:
  - "Completed outcome: Strapi to Payload CMS migration: step-by-step guide to map schemas, convert Slate to Lexical rich text, remap IDs, and import data. Start migrating today."
---

**Summary Triples**
- (Strapi Collection Type, mapsTo, Payload collection)
- (Strapi Single Type, mapsTo, Payload global)
- (Repeatable Strapi Component, mapsTo, Payload Array of Blocks)
- (Non-repeatable Strapi Component, mapsTo, Payload Named Group field)
- (Strapi Dynamic Zone, mapsTo, Payload Blocks field (polymorphic))
- (Strapi Relation fields, require, two-pass ID remapping or relationship resolution during import)
- (Slate rich text, mustBeConvertedTo, Lexical rich text for Payload admin compatibility)
- (ID remapping, recommendedMethod, two-pass import: create records, record old->new IDs, update relationships)
- (Media, handledBy, Payload Upload collection (migrate files, preserve paths/metadata))
- (Payload schema, shouldBeAuthoredIn, TypeScript (strongly typed collections recommended))
- (Postgres imports, canUse, @payloadcms/db-postgres to write directly into Payload schema tables)
- (Admin UI customizations, differFrom, Strapi; require rebuilding with Payload admin components or React)

### {GOAL}
Strapi to Payload CMS migration: step-by-step guide to map schemas, convert Slate to Lexical rich text, remap IDs, and import data. Start migrating today.

### {PREREQS}
- Access to Strapi v4
- Access to Strapi v5
- Access to Payload CMS
- Access to TypeScript
- Access to Node.js

### {STEPS}
1. Map Strapi content types to Payload
2. Recreate collections in TypeScript and Payload
3. Export Strapi data using CLI export
4. Transform and import entities into Payload
5. Convert Slate rich text to Lexical
6. Run two-pass ID remapping for relations
7. Migrate media and upload collections
8. Customize admin UI with React components
9. Validate, test, and finalize deployment

<!-- llm:goal="Strapi to Payload CMS migration: step-by-step guide to map schemas, convert Slate to Lexical rich text, remap IDs, and import data. Start migrating today." -->
<!-- llm:prereq="Access to Strapi v4" -->
<!-- llm:prereq="Access to Strapi v5" -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to Node.js" -->
<!-- llm:output="Completed outcome: Strapi to Payload CMS migration: step-by-step guide to map schemas, convert Slate to Lexical rich text, remap IDs, and import data. Start migrating today." -->

# Complete Strapi to Payload CMS Migration Guide — 7 Steps
> Strapi to Payload CMS migration: step-by-step guide to map schemas, convert Slate to Lexical rich text, remap IDs, and import data. Start migrating today.
Matija Žiberna · 2026-04-05

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](/payload-cms-vs-strapi) 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.

| Strapi | Payload |
|---|---|
| Collection Type | Collection |
| Single Type | Global |
| Component (repeatable) | Array of Blocks |
| Component (non-repeatable) | Named Group field |
| Dynamic Zone | Blocks field (polymorphic) |
| Relation | Relationship field |
| Media | Upload 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:

```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:

```typescript
// 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.

```typescript
// 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](/blog/payloadcms-postgres-push-to-migrations) 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:

```bash
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:

```typescript
// 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()
```

> [!NOTE]
> 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`](/blog/payload-cms-sdk-cli-toolkit) 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:

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

In Payload's Lexical format (`SerializedHeadingNode`):

```json
{
  "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:

```typescript
// 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
}
```

> [!WARNING]
> 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:

```typescript
// 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:

```typescript
// 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.

> [!TIP]
> 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.

```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](/blog/payload-cms-custom-admin-ui-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](/blog/best-headless-cms-nextjs-payload-2026) 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](/payload-cms-migration) 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

## LLM Response Snippet
```json
{
  "goal": "Strapi to Payload CMS migration: step-by-step guide to map schemas, convert Slate to Lexical rich text, remap IDs, and import data. Start migrating today.",
  "responses": [
    {
      "question": "What does the article \"Complete Strapi to Payload CMS Migration Guide — 7 Steps\" cover?",
      "answer": "Strapi to Payload CMS migration: step-by-step guide to map schemas, convert Slate to Lexical rich text, remap IDs, and import data. Start migrating today."
    }
  ]
}
```