Now booking enterprise content platform builds for 2026. Contact us

CMS migrations Wagtail

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.

  • From Wagtail
  • To Payload
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 fieldPayload fieldNotes
CharFieldtextDirect
TextFieldtextareaDirect
RichTextFieldrichText (Lexical)HTML with embeds — see below
StreamFieldblocksThe closest analogue — see below
IntegerFieldnumberDirect
DecimalField / FloatFieldnumberDirect
BooleanFieldcheckboxDirect
DateField / DateTimeFielddateDirect
EmailFieldemailBuilt-in validation
URLField / SlugFieldtextSlug → unique: true
ForeignKey (page / snippet)relationshipTwo-pass import
Image (Wagtail Images)uploadMigrate images first
DocumentuploadSame media pipeline
ChoiceFieldselectCarry the choices into options
ParentalKey (inline child)arrayOrderable 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 group with 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.


Rather have it done for you? WAYF runs Wagtail to Payload migrations end to end.

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.