- 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.

Need Help Making the Switch?
Moving to Next.js and Payload CMS? I offer advisory support on an hourly basis.
Book Hourly AdvisoryIf 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.
| 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:
// 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.