Migrate from Contentful to Payload
A working reference for moving a Contentful space onto Payload: a field-by-field mapping table, the export-and-import pipeline that actually runs, and the two field types where every migration spends its time.
Payload Partner · Top Contributor We build and maintain Payload plugins used by the wider community. Direct access to the maintainers when something needs attention.If you are scoping a move off Contentful, the question underneath the search is usually the same one: how much of this maps cleanly, and where does it get expensive. The honest answer is that most of the field model transfers in an afternoon, and two field types carry the rest of the budget. This page covers both, in the order you hit them.
WAYF is a Payload Partner agency and a top contributor to its open source. The mechanics below are how the migration runs in practice.
If the Salesforce acquisition brought you here: the deal is signed and the close window runs to 31 October 2026. Our action plan for the open window covers what to pull from your own contract and which public signals to watch before committing to anything.
Field mapping
Every Contentful content type becomes a Payload collection. Every field inside it maps to a Payload field. The table covers the complete set of Contentful field types.
| Contentful type | Payload field | Notes |
|---|---|---|
| Short text (Symbol) | text | 256-char cap; carry it as maxLength or drop it |
| Short text, list | array of text | Each symbol becomes one row |
| Long text | textarea | Markdown body → use richText instead |
| Rich text | richText (Lexical) | Needs a tree transformer — see below |
| Integer | number | Set admin.step: 1 to keep integer entry |
| Decimal | number | Direct |
| Boolean | checkbox | Default value carries to defaultValue |
| Date & time | date | ISO 8601 in; timezone handling differs |
| Location | point | Coordinate order flips to [lon, lat] |
| Media, one file | upload | Migrate assets before entries |
| Media, many files | upload, hasMany: true | Same ID map |
| Reference, one entry | relationship | Two-pass import — see below |
| Reference, many entries | relationship, hasMany: true | Two-pass import |
| JSON object | json | Direct; add validate if you need a schema |
Three rows in that table hide the work: rich text, references, and media. Everything else is a value copy. The sections below are about those three.
The two-pass problem
A Contentful reference field stores a link, not the data:
{ "author": { "en-US": { "sys": { "type": "Link", "linkType": "Entry", "id": "5xK2..." } } } }
That id is a Contentful entry ID. Payload uses its own document IDs, which do not exist until you create the documents. So you cannot resolve a relationship on the same pass that creates the entry holding it. Circular references (an author who links a featured post that links back to the author) make a single ordered pass impossible.
The migration runs in two passes:
- Create every entry with relationship fields left empty, recording a map of
contentfulEntryId → payloadDocumentIdas you go. - Update every entry, swapping each stored Contentful ID for the Payload ID from that map.
Write the ID map to disk before pass two starts. If the run dies halfway, you want to resume without re-reading the whole space.
How the migration runs
Export the space
The contentful-export CLI pulls content, schema, and asset files in one command:
npx contentful-export \
--space-id "$CONTENTFUL_SPACE_ID" \
--management-token "$CONTENTFUL_MANAGEMENT_TOKEN" \
--download-assets \
--output-file contentful-export.json
The JSON holds contentTypes, entries, and assets. --download-assets writes the binary files alongside it. On a large space the export runs long and the file gets big; read the contentTypes array first to inventory what you are dealing with before writing any transform code.
Migrate assets first
Relationship and upload fields point at asset documents, so assets exist before entries. For each asset, create a Payload upload and record its new ID:
const fileMeta = asset.fields.file["en-US"];
const buffer = await fs.readFile(localPathFor(asset));
const media = await payload.create({
collection: "media",
data: { alt: asset.fields.description?.["en-US"] ?? "" },
file: {
data: buffer,
name: fileMeta.fileName,
mimetype: fileMeta.contentType,
size: buffer.byteLength,
},
});
assetIdMap.set(asset.sys.id, media.id);
Import entries, then resolve references
Pass one creates entries with relationships blank. Contentful keys every field value by locale, so a single-locale space reads from ["en-US"]:
function readField<T>(field: Record<string, T> | undefined): T | undefined {
return field?.["en-US"];
}
// Pass 1 — create, capture the ID map
const doc = await payload.create({
collection: "posts",
data: {
title: readField(entry.fields.title),
body: toLexical(readField(entry.fields.body)),
hero: assetIdMap.get(readField(entry.fields.hero)?.sys.id ?? ""),
// relationships left undefined here
},
});
entryIdMap.set(entry.sys.id, doc.id);
// Pass 2 — resolve relationships now that every Payload ID exists
await payload.update({
collection: "posts",
id: entryIdMap.get(entry.sys.id)!,
data: {
author: entryIdMap.get(readField(entry.fields.author)?.sys.id ?? ""),
related: (readField(entry.fields.related) ?? [])
.map((link) => entryIdMap.get(link.sys.id))
.filter(Boolean),
},
});
Rich text: Contentful to Lexical
This is where the time goes. Contentful Rich Text is a JSON tree in its own format. Payload’s default editor is Lexical, a different JSON tree. You walk one and emit the other.
The node mapping:
| Contentful node | Lexical node |
|---|---|
document | root |
paragraph | paragraph |
heading-1 … heading-6 | heading, tag: h1…h6 |
unordered-list | list, listType: bullet |
ordered-list | list, listType: number |
list-item | listitem |
blockquote | quote |
hr | horizontalrule |
hyperlink | link, external URL |
entry-hyperlink | link, internal relationship |
asset-hyperlink | link to a media doc |
embedded-asset-block | upload block |
embedded-entry-block | custom block node |
text | text |
Text styling differs in kind. Contentful attaches a marks array to each text node; Lexical encodes styles as a bitmask on a format integer:
const FORMAT = { bold: 1, italic: 2, underline: 8, code: 16 } as const;
function marksToFormat(marks: { type: keyof typeof FORMAT }[]): number {
return marks.reduce((acc, m) => acc | (FORMAT[m.type] ?? 0), 0);
}
Two cases are genuinely hard:
- Embedded entries (
embedded-entry-block,embedded-entry-inline) carry only asys.id. Like top-level references, you cannot resolve them until the target document exists, so embedded-entry nodes belong in pass two. Emit a placeholder node in pass one and rewrite it once the ID map is complete. - The exact Lexical shape shifts between Payload versions. Create one document by hand in the Payload admin, read its
richTextvalue straight from the database, and treat that as the target shape your transformer has to produce. Building against a guessed shape wastes a day.
Plan two to three days for the transformer when content uses embedded entries. A space that only uses headings, paragraphs, lists, and links is closer to half a day.
Localization
A multi-locale Contentful space stores every value under its locale key:
{ "title": { "en-US": "Home", "de": "Startseite" } }
Payload keeps locale variants under one document ID, selected by a locale argument on each write. Import the default locale first, then update the same document once per additional locale:
const doc = await payload.create({ collection: "posts", data: en, locale: "en" });
await payload.update({ collection: "posts", id: doc.id, data: de, locale: "de" });
Map the locale codes before you start. Contentful’s en-US is rarely the same string as your Payload locale identifier.
Redirects and SEO
The risk a migration carries is not lost content. It is lost rankings, when URLs move and nothing forwards the old ones. Before cutover, export the live URL set and produce a redirect for every path whose structure changes. Payload’s redirects plugin holds these as managed documents your editors can see and maintain, rather than a config file nobody opens again. On the Ingersoll Rand platform that meant tens of thousands of redirects, each verified against the legacy build before launch.
What it costs
A single-locale space, under 5,000 entries, no embedded-entry rich text, a clean model — a typical marketing site or docs hub — is three to five engineering days end to end: schema derivation, the pipeline, asset migration, and a cutover rehearsal.
A space with several locales, deeply nested rich text with embedded entries, large asset libraries, and circular references is a three-to-four-week project, and the rich-text transformer is the long pole. If that sounds like your space, the place to find out for real is a scoped discovery, not a guess off a search result.
If you want an honest read on what moving your specific Contentful space 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.