Migrate from Wagtail to Payload
A working reference for moving a Wagtail site onto Payload: a field-by-field mapping table, how StreamField and the page tree translate, and the Python-to-TypeScript export pipeline that the migration actually runs through.
Payload Partner · Top Contributor We build and maintain Payload plugins used by the wider community. Direct access to the maintainers when something needs attention.Wagtail and Payload are both code-first CMSs with the schema in version control, so the model maps over well. The two things that make a Wagtail migration its own job are StreamField, which is genuinely close to Payload’s blocks, and the page tree, which Payload does not have a direct equivalent for. There is also a practical wrinkle: Wagtail is Python and Payload is TypeScript, so the export and import live on opposite sides of a language boundary.
WAYF is a Payload Partner agency and a top contributor to its open source.
Field mapping
Each Wagtail page model and snippet becomes a Payload collection, each model field a Payload field. The table covers the field types a Wagtail project uses.
| Wagtail field | Payload field | Notes |
|---|---|---|
| CharField | text | Direct |
| TextField | textarea | Direct |
| RichTextField | richText (Lexical) | HTML with embeds — see below |
| StreamField | blocks | The closest analogue — see below |
| IntegerField | number | Direct |
| DecimalField / FloatField | number | Direct |
| BooleanField | checkbox | Direct |
| DateField / DateTimeField | date | Direct |
| EmailField | email | Built-in validation |
| URLField / SlugField | text | Slug → unique: true |
| ForeignKey (page / snippet) | relationship | Two-pass import |
| Image (Wagtail Images) | upload | Migrate images first |
| Document | upload | Same media pipeline |
| ChoiceField | select | Carry the choices into options |
| ParentalKey (inline child) | array | Orderable child models → array rows |
The rows that carry the work are StreamField, foreign keys, images and documents, and the page tree that holds it all together.
StreamField to blocks
StreamField is Wagtail’s block-based body field, and it lines up with Payload’s blocks field directly. StreamField stores a JSON list where each entry has a type, a value, and an id:
[
{ "type": "hero", "value": { "heading": "...", "image": 12 }, "id": "a1b2" },
{ "type": "quote", "value": { "text": "...", "attribution": "..." }, "id": "c3d4" }
]
Map each top-level block type to a Payload block, mirroring the subfields. The nested block types each have a rule:
- StructBlock (a group of fields) becomes a Payload block or
groupwith matching subfields. - ListBlock (a repeated block) becomes a Payload
array. - StreamBlock nested inside another block becomes nested Payload
blocks. - ImageChooserBlock holds an image ID — resolve it through the media map to a Payload
upload. - PageChooserBlock holds a page ID — resolve it on the second pass to a Payload
relationship.
Defining one Payload block per StreamField block type is the bulk of the schema work on a StreamField-heavy site.
The page tree
This is the part Wagtail has and Payload does not model out of the box. Wagtail pages form a tree: every page is a node with a slug, a computed url_path, a depth, and a parent, stored through django-treebeard. Payload collections are flat.
Reconstruct the hierarchy with a self-referential relationship field — parent, pointing at the same collection — plus the stored slug and a url_path (or breadcrumb) field you compute during the migration. Import the tree top-down, parents before children, so each page’s parent relationship resolves to an existing Payload document. Keep the original url_path on each page so you can generate redirects for any URL that changes shape, which is the difference between keeping and losing the site’s search rankings at cutover.
How the migration runs
Export with the Django ORM
Wagtail has no single export command that captures StreamField internals, the tree, and draft state together, so the reliable route is a Django management command that walks the ORM and writes JSON. Use .specific() so each page serializes as its concrete model, and take StreamField as raw data so block structure survives:
# management/commands/export_pages.py
import json
from wagtail.models import Page
def handle(self, *args, **options):
out = []
for page in Page.objects.all().specific().order_by("path"):
out.append({
"id": page.id,
"model": page.specific_class.__name__,
"slug": page.slug,
"url_path": page.url_path,
"parent_id": page.get_parent().id if page.depth > 1 else None,
"live": page.live,
"fields": page.specific.serializable_data(), # includes StreamField raw_data
})
print(json.dumps(out))
Ordering by path gives you parents before children for free. The Wagtail API v2 (/api/v2/pages/?fields=*) is an alternative, but it exposes less of the draft and block internals, so the ORM script is the senior choice.
Import into Payload with TypeScript
The JSON the Python command emits is the handoff across the language boundary. A TypeScript importer reads it and writes to Payload. Migrate images and documents first, recording the media map, then import pages top-down:
const pages = JSON.parse(await fs.readFile("pages.json", "utf8"));
// Pass 1 — create top-down, blank page relationships, record id map
for (const p of pages) {
const doc = await payload.create({
collection: COLLECTION_FOR[p.model],
data: {
slug: p.slug,
urlPath: p.url_path,
parent: p.parent_id ? pageIdMap.get(p.parent_id) : undefined,
body: streamFieldToBlocks(p.fields.body, mediaIdMap),
_status: p.live ? "published" : "draft",
},
});
pageIdMap.set(p.id, doc.id);
}
Because the export is ordered by tree path, parents are created before children and the parent relationship resolves in the same pass. Foreign keys and PageChooserBlock references that point sideways across the tree still resolve on a second pass.
Rich text: Wagtail HTML and embeds
RichTextField stores a restricted HTML with Wagtail’s own embed and link entities — images as <embed embedtype="image" id="10" .../>, internal links as <a linktype="page" id="3">, documents as <a linktype="document" id="1">. A plain HTML-to-Lexical pass is not enough; the transform has to resolve those entity IDs. Map embedtype="image" and linktype="document" through the media map, and linktype="page" through the page ID map on the second pass. Create one document by hand in the Payload admin and read its stored richText value to confirm the exact Lexical node shape your transformer must produce.
Localization and revisions
Wagtail’s internationalisation links translated pages through a shared translation_key and a Locale. Group pages by translation_key, import the source locale first to create the Payload document, then update the same document for each additional locale, mapping Wagtail’s locale codes onto your Payload locale identifiers.
Wagtail keeps page revisions with a live/draft distinction (page.live, has_unpublished_changes). Enable Payload’s drafts through its versions feature and set each page’s status from live, so an unpublished Wagtail page lands as a draft rather than going live on import.
What it costs
A site with a handful of page models, light StreamField use, and a single locale is three to five engineering days: schema derivation, the ORM export, the media pipeline, and a cutover rehearsal.
A large site — many page models, deep StreamField nesting with StructBlocks and ListBlocks, a big page tree, RichText embeds throughout, multiple locales, and draft state to preserve — is a three-to-four-week migration. The block definitions, the tree reconstruction, and the RichText entity resolution are the long poles. A scoped discovery is the place to size yours.
If you want an honest read on what moving your specific Wagtail site 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.