Migration von WordPress zu Payload CMS: ein Playbook im Überblick
WordPress ist der Weg des geringsten Widerstands, um eine Content-Website online zu bringen, und der Weg des größten Widerstands, sobald Sie ihm entwachsen sind. Dieses Playbook bildet die Migration in fünf Etappen ab und nennt die Regeln, die an jedem Schritt Inhalte und SEO intakt halten.
WordPress ist der Weg des geringsten Widerstands, um eine Content-Website online zu bringen. Es wird zum Weg des größten Widerstands, sobald Sie ihm entwachsen sind. Irgendwann kosten der Wildwuchs an Plugins, die Schlüssel-Wert-Suppe in wp_postmeta und das starre Templating mehr, als sie sparen, und ein Wechsel zu einem code-first, typsicheren CMS wie Payload beginnt attraktiv zu wirken.
Wir haben diese Migration oft genug durchgeführt, um eine Karte davon zu haben, und das hier ist diese Karte. Sie ist bewusst allgemein gehalten (keine projektspezifischen Schemata), und die Payload-Ausschnitte sind illustrativ: Sie zeigen die Form, nicht Code zum Einfügen in Ihr Repository.
Wir führen jede Migration von WordPress zu Payload gleich durch, in dieser Reihenfolge:
| # | Schritt | Was es ist | Die eine Regel |
|---|---|---|---|
| 1 | Extrahieren | Daten aus WordPress herausziehen (WXR/XML, Datenbank oder REST/WooCommerce-CSV) in flaches JSON auf der Festplatte | Einmal extrahieren, hundertmal transformieren. Niemals live gegen WordPress transformieren |
| 2 | Modellieren | Das typisierte Payload-Ziel entwerfen: eine Collection je post_type, echte Felder statt Meta-Suppe | Tragen Sie in jedem Dokument eine legacyWpId mit, damit Sie Beziehungen und Weiterleitungen später verdrahten können |
| 3 | Transformieren | Das flache JSON auf Ihre Collection-Formen abbilden: HTML zu Lexical, Taxonomien zu Beziehungen | Machen Sie den Loader idempotent: Upsert auf den Altschlüssel, niemals blind anlegen |
| 4 | Laden | In Abhängigkeitsreihenfolge in Payload schreiben: Medien → Taxonomien → Inhalte → Beziehungslauf | Schalten Sie afterChange-Nebenwirkungen (Revalidierung, Suche, Webhooks) während des Massenimports ab |
| 5 | Weiterleiten | Alte Permalinks auf neue Pfade abbilden; 301 für Verschobenes, 410 für Entferntes | Zerbrechen Sie keine indexierten URLs. Diesen Schritt unterschätzen alle |
Der Rest des Artikels geht jeden Schritt im Detail durch.
Warum das schwerer ist als „exportieren und importieren“
Das naive Denkmodell lautet: WordPress abziehen, in Payload laden, fertig. In der Praxis steckt die Arbeit in der Impedanzfehlanpassung zwischen den beiden Systemen:
| WordPress | Payload |
|---|---|
Alles ist ein post mit einem post_type als Unterscheidungsmerkmal | Eigenständige, streng typisierte Collections |
Felder leben in wp_postmeta als untypisierte Schlüssel-Wert-Zeilen | Typisierte Felder auf einer Konfiguration, beim Schreiben validiert |
Inhalt ist ein HTML-Klumpen (post_content) | Strukturierter Lexical-Rich-Text (JSON) oder Blöcke |
| Beziehungen sind IDs, vergraben in Meta- oder Taxonomietabellen | Erstklassige Beziehungsfelder mit referenzieller Prüfung |
Medien sind Dateien plus wp_attachments-Zeilen | Eine Upload-fähige Collection mit eigenem Speicher-Adapter |
URLs sind Permalinks, geformt von .htaccess-Regeln | Was immer Sie in Ihrem Frontend an Routing bauen |
Der Großteil der Arbeit besteht darin, Daten über diese Tabelle hinweg umzuformen, dazu die unspektakuläre, aber entscheidende Aufgabe, bestehende URLs nicht zu zerbrechen. Wir behandeln das als ETL-Projekt und planen die Stunden entsprechend.
Die Schritte im Detail
1. Extrahieren
Zuerst holen wir die Daten aus WordPress heraus und in etwas, woran wir offline arbeiten können. Drei gängige Quellen, grob nach Detailtreue geordnet:
- Der WXR/XML-Export (
Werkzeuge → Daten exportieren). Leicht zu bekommen, aber verlustbehaftet: Er flacht benutzerdefinierte Felder ab und verstümmelt serialisiertes PHP häufig. Für Beiträge und Seiten in Ordnung, für E-Commerce schwach. - Direkter Datenbankzugriff (
wp_posts,wp_postmeta,wp_terms, …). Die vollständigste Quelle. Sie fragen die Beziehungen selbst ab und verlieren nichts. - Die REST-API (
/wp-json/wp/v2/...) oder WooCommerce-CSV-Exporte für Shop-Daten. Bequem und bereits JSON-nah, aber paginiert und mit Rate-Limits versehen.
Welche Quelle auch immer, das Ziel dieses Schritts ist stets dasselbe: flache JSON-Dateien auf der Festplatte, die wir hundertmal neu einlesen können, ohne WordPress noch einmal anzufassen.
// Extrahieren: WXR/XML -> normalisiertes JSON auf der Festplatte
import { XMLParser } from 'fast-xml-parser'
import { readFile, writeFile } from 'node:fs/promises'
const xml = await readFile('export.wordpress.xml', 'utf8')
const parsed = new XMLParser({ ignoreAttributes: false }).parse(xml)
const items = parsed.rss.channel.item.map((item) => ({
wpId: item['wp:post_id'],
type: item['wp:post_type'], // post | page | product | attachment ...
status: item['wp:status'], // publish | draft | trash
slug: item['wp:post_name'],
title: item.title,
contentHtml: item['content:encoded'],
// wp:postmeta ist die untypisierte Suppe: die Schlüssel abflachen, die zählen
meta: Object.fromEntries(
[item['wp:postmeta']].flat().filter(Boolean).map((m) => [m['wp:meta_key'], m['wp:meta_value']]),
),
}))
await writeFile('data/posts.json', JSON.stringify(items, null, 2))
Faustregel: Lassen Sie Ihren Transformationscode niemals direkt mit WordPress sprechen. Einmal extrahieren, das JSON einchecken, wiederholt transformieren. Das macht die ganze Sache wiederholbar und prüfbar.
2. Modellieren
Bevor irgendetwas transformiert wird, entwerfen wir das Ziel. Das ist der Teil, zu dem WordPress Sie nie gezwungen hat, und daher stammt der größte Teil des langfristigen Werts der Migration.
Für jeden WordPress-post_type entscheiden wir, was er in Payload wird (meist eine eigene Collection) und welche Felder er trägt. Der Denkwechsel ist einfach: keine untypisierten Meta-Daten mehr speichern, stattdessen typisierte Felder deklarieren.
// Modellieren: eine Payload-Collection, das typisierte Ziel für "post"
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: { useAsTitle: 'title' },
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text', required: true, unique: true, index: true },
{ name: 'content', type: 'richText' }, // Lexical-JSON, nicht HTML
{ name: 'featuredImage', type: 'upload', relationTo: 'media' },
{ name: 'categories', type: 'relationship', relationTo: 'categories', hasMany: true },
{ name: 'publishedAt', type: 'date' },
// die alte ID während der Migration mitführen, um Beziehungen später aufzulösen
{ name: 'legacyWpId', type: 'number', index: true, admin: { readOnly: true } },
],
}
Zwei Modellierungsgewohnheiten zahlen sich erheblich aus:
- Führen Sie ein Feld
legacyWpId. Beziehungen werden in WordPress als alte IDs ausgedrückt. Sie können „die Kategorie dieses Beitrags“ nicht auflösen, bevor beide Seiten in Payload existieren, also importieren wir alles mitsamt seiner alten ID und verdrahten die Beziehungen in einem zweiten Lauf, indem wir die alten IDs nachschlagen. Lassen Sie das Feld danach fallen, oder behalten Sie es für die Weiterleitungen (siehe Schritt 5). - Zerlegen Sie den HTML-Klumpen.
post_contentist in WordPress ein Feld. In Payload lohnt es sich oft, ihn in strukturierte Blöcke zu zerlegen (Hero, Galerie, Zitat, Callout), damit Redakteure einen echten Seitenbaukasten bekommen statt einer Wand aus HTML. Diese Zerlegung ist optional, aber sie ist der größte einzelne Ertrag auf die Frage, wozu der Aufwand gut war.
3. Transformieren
Jetzt bilden wir das flache JSON auf die Collection-Formen ab. Zwei Umwandlungen sind hartnäckig lästig:
HTML zu Lexical-Rich-Text. Payloads Editor speichert einen JSON-Baum, kein HTML. Wir wandeln das WordPress-HTML in diesen Baum um (Payload liefert dafür einen Helfer im Stil von editorConfigFactory / convertHTMLToLexical). Dafür planen wir echte Zeit ein: WordPress-HTML steckt voller Shortcodes, Inline-Styles und Einbettungen, die sich nicht sauber abbilden lassen.
Taxonomien zu Beziehungen. wp_terms und wp_term_relationships werden zu Payload-Beziehungsfeldern. Wir importieren die Taxonomien zuerst als eigene Collections und lösen dann auf.
// Transformieren + Laden: idempotenter Upsert auf die Alt-ID
import { getPayload } from 'payload'
import config from '@payload-config'
const payload = await getPayload({ config })
for (const item of posts) {
const existing = await payload.find({
collection: 'posts',
where: { legacyWpId: { equals: item.wpId } },
limit: 1,
})
const data = {
title: item.title,
slug: item.slug,
content: htmlToLexical(item.contentHtml), // Ihr HTML-zu-Lexical-Schritt
publishedAt: item.publishedAt,
legacyWpId: item.wpId,
}
if (existing.docs[0]) {
await payload.update({ collection: 'posts', id: existing.docs[0].id, data })
} else {
await payload.create({ collection: 'posts', data })
}
}
Machen Sie den Loader idempotent: Upsert auf
legacyWpId, niemals blind anlegen. Sie werden ihn mehr als einmal laufen lassen, und ein erneuter Lauf soll konvergieren, nicht duplizieren.
4. Laden (in der richtigen Reihenfolge, mit abgeschalteten Hooks)
Die Ladereihenfolge zählt wegen der Beziehungen: Medien und Taxonomien vor den Inhalten, die auf sie verweisen.
Medien → Kategorien / Tags → Beiträge / Seiten → Beziehungen auflösen (2. Lauf)
Zwei betriebliche Hinweise für Massenimporte:
- Medien zuerst, indem die Binärdaten gestreamt werden. Wir richten Payloads Upload auf die alten Datei-URLs und lassen es sie in den Speicher-Adapter ziehen (S3, lokal usw.). Behalten Sie die alte Anhang-ID in jedem Medien-Dokument, damit sich die Verweise auf Beitragsbilder später auflösen lassen.
- Schalten Sie Nebenwirkungen während des Imports ab. Payload führt bei jedem Schreibvorgang
afterChange-Hooks aus (Cache-Revalidierung, Suchindizierung, Webhooks). Bei einem Massenimport von 10.000 Zeilen sind das 10.000 Cache-Invalidierungen, die Sie nicht wollen. Übergeben Sie ein Kontext-Flag und brechen Sie in Ihren Hooks früh ab:
await payload.create({
collection: 'posts',
data,
context: { disableRevalidate: true }, // Ihre Hooks prüfen das und steigen früh aus
})
5. Weiterleiten: der Schritt, den alle unterschätzen
Ihre alten WordPress-URLs sind bei Google indexiert, von anderen Websites verlinkt und von Nutzern als Lesezeichen gespeichert. Wenn /2019/03/mein-beitrag/ nach dem Launch mit 404 antwortet, verlieren Sie diesen Traffic und diese SEO.
Wir bauen eine Weiterleitungstabelle vom alten Permalink auf den neuen Pfad und liefern 301 (dauerhaft) für verschobene Inhalte und 410 (entfernt) für alles, was wir bewusst fallen gelassen haben. Genau hier zahlen sich die legacyWpId und die alten Slug-Daten aus, die wir mitgeführt haben.
// next.config oder Middleware: alte WP-Permalinks per 301 auf neue Routen
export const redirects = async () => [
{ source: '/:year(\\d{4})/:month(\\d{2})/:slug', destination: '/blog/:slug', permanent: true },
{ source: '/?p=:id', destination: '/blog/:slug', permanent: true }, // aufgelöst über die Alt-ID-Tabelle
]
Überspringen Sie die 410er nicht. Suchmaschinen zu sagen, dass eine Seite absichtlich fort ist, ist weit sauberer, als tausend Soft-404s in der Search Console vor sich hin faulen zu lassen.
Lokalisierung
WordPress hat kein natives mehrsprachiges Modell: Übersetzungen leben in einem Plugin. WPML und Polylang speichern jede Übersetzung als eigene wp_posts-Zeile, verknüpft zu einer Übersetzungsgruppe über eine Nebentabelle (icl_translations bei WPML, begriffsbasierte Verknüpfungen bei Polylang), wobei eine Sprache als Original markiert ist. Payload lokalisiert andersherum. Es lokalisiert auf Feldebene: Der Wert jeder Sprache liegt unter einer Dokument-ID und wird über ein locale-Argument bei jedem Schreibvorgang ausgewählt. Die Migration führt also die getrennten Beiträge je Sprache aus WordPress auf Schreibvorgänge je Locale desselben Dokuments zusammen.
Wir gruppieren die übersetzten Beiträge nach ihrer Übersetzungsgruppe und importieren die Originalsprache zuerst: Dieser Schreibvorgang erzeugt das Basisdokument. Danach legen wir jede weitere Sprache auf dieselbe Dokument-ID, eine Locale nach der anderen. Wir bilden die Sprachcodes des Plugins vorab auf unsere Payload-Locale-Kennungen ab und fallen auf die Originalsprache zurück, wenn eine Übersetzung fehlt.
Die Falle mit lokalisierten Arrays. Payload lokalisiert Felder, keine Arrays. Wenn ein Beitrag ein wiederholtes Feld hat (Blöcke, eine Galerie, FAQ-Zeilen) und Sie dieses Array einmal je Locale schreiben, überschreibt jeder Schreibvorgang das gesamte Array, statt zu verschmelzen, sodass die zweite Locale die lokalisierten Werte, die die erste in diese Zeilen geschrieben hat, stillschweigend auslöscht. Die Regel, die das vermeidet: Lesen Sie das aktuelle Dokument mit locale: "all", um die Daten jeder Locale zu erhalten; ordnen Sie bestehende Zeilen über einen stabilen Schlüssel zu (den Slug, die legacyWpId) und führen Sie deren Zeilen-id weiter, denn Zeilen ohne id werden bei jedem Lauf neu erzeugt und verlieren die Daten vorheriger Locales; und führen Sie einen Schreibvorgang je Locale aus, der den vollständigen zusammengeführten Zustand trägt, alle skalaren Felder und das komplette Array zusammen, niemals einen zweiten Nachschreibvorgang für das Array allein.
// 1) Originalsprache zuerst: dieser Schreibvorgang erzeugt das Basisdokument
const base = await payload.create({
collection: 'posts',
locale: defaultLocale, // z. B. "en"
data: mapPost(originalPost), // skalare Felder + Array-Zeilen
})
idMap.set(originalPost.wpId, base.id)
// 2) Jede Übersetzung auf DIESELBE Dokument-ID legen
for (const translation of otherLanguages) {
// Jede Locale schnappschussartig lesen, damit wir die anderen nicht überschreiben
const current = await payload.findByID({
collection: 'posts',
id: base.id,
locale: 'all',
})
// Bestehende Zeilen über einen stabilen Schlüssel zuordnen und ihre id weiterführen
const rowIds = new Map(
(current.blocks ?? []).map((r) => [r.legacyKey, r.id]),
)
const blocks = mapRows(translation).map((r) => ({
...(rowIds.has(r.legacyKey) ? { id: rowIds.get(r.legacyKey) } : {}),
...r,
}))
// Ein Schreibvorgang je Locale, der den vollständigen zusammengeführten Zustand trägt
await payload.update({
collection: 'posts',
id: base.id,
locale: toPayloadLocale(translation.language), // z. B. "pl", "de"
data: { ...mapScalars(translation), blocks },
context: { disableRevalidate: true },
})
}
Eine pragmatische Checkliste
- In flaches JSON auf der Festplatte extrahieren; niemals live gegen WordPress transformieren.
- Jeden
post_typeals typisierte Collection modellieren, bevor Transformationscode geschrieben wird. - In jedem Dokument eine
legacyWpId(und den alten Slug) mitführen, um Beziehungen und Weiterleitungen aufzulösen. -
post_content-HTML zu Lexical umwandeln (oder in Blöcke zerlegen). - Den Loader idempotent machen: Upsert auf den Altschlüssel.
- In Abhängigkeitsreihenfolge laden: Medien → Taxonomien → Inhalte → Beziehungslauf.
- Hooks für Revalidierung, Suche und Webhooks während des Massenimports abschalten.
- Übersetzungsgruppen des Plugins auf Schreibvorgänge je Locale abbilden: die Originalsprache zuerst importieren, dann Übersetzungen auf dieselbe Dokument-ID legen; ein wiederholtes Feld niemals einmal je Locale schreiben, ohne zu verschmelzen.
- Eine 301/410-Weiterleitungstabelle aus den alten Permalinks bauen; im Staging prüfen.
- Anzahlen abgleichen (WordPress-Zeilen gegen Payload-Dokumente) und gerenderte Seiten vor dem Umschalten stichprobenartig prüfen.
Wann Sie auf WordPress bleiben sollten
Nicht jede WordPress-Website sollte umziehen, und es lohnt sich, ehrlich darüber zu sein, wann sie es nicht sollte. WordPress verdient seinen Platz, wenn die Website inhaltlich einfach ist und die Menschen, die sie betreiben, keine Entwickler sind: ein Blog, eine Broschüren-Website oder eine Marketing-Website, deren Team sich auf das Plugin-Ökosystem und den vertrauten Editor stützt. Wenn eine Handvoll gut gepflegter Plugins abdeckt, was Sie brauchen, und niemand eine Codebasis besitzen möchte, tut WordPress seine Arbeit, und eine Migration fügt Kosten und Entwicklungsaufwand hinzu, die Sie nicht benötigen.
Der Fall für Payload beginnt, wenn Sie ohnehin schon gegen WordPress arbeiten: Das Inhaltsmodell ist über Beiträge und Meta-Daten hinausgewachsen, Sie pflegen eigene Plugins, um ihm Struktur aufzuzwingen, die redaktionelle Arbeit wird durch Plugin-Wildwuchs ausgebremst, oder Sie wollen die Inhalte in einem typisierten, versionierten Schema haben, gegen das Ihr Team entwickeln kann. Das ist der Punkt, an dem der Umzug der Inhalte in den Code zurückverdient, was die Migration kostet.
Was Sie auf der anderen Seite bekommen
Sind die Daten erst in Payload, summieren sich die Gewinne: Redakteure arbeiten gegen typisierte Felder statt gegen Meta-Suppe, Inhalte sind strukturiertes JSON, das Sie überall ausspielen können (Web, native App, E-Mail), und Ihr Schema liegt in der Versionsverwaltung, wo es wie jeder andere Code geprüft und migriert werden kann. Die Migration ist eine einmalige Abgabe. Sie zu zahlen kauft Ihnen ein CMS, das sich wie ein Teil Ihrer Codebasis verhält statt wie eine Blackbox, um die herum Sie integrieren.
Wie WAYF helfen kann
Wir sind offizieller Payload-Partner und Top Contributor, und wir haben diese Migration an echten WordPress-Websites durchgeführt. Wenn Sie einen Wechsel weg von WordPress abwägen, buchen Sie ein Gespräch, und wir sagen Ihnen, ob Payload den Wechsel verdient oder ob WordPress weiterhin passt. Unter unseren Arbeiten sehen Sie Plattformen, die wir gebaut haben.
Wir nehmen Content-Plattform-Projekte für 2026 an.
Fünfundzwanzig Minuten, um die Arbeit durchzugehen und zu entscheiden, ob wir das richtige Team dafür sind. Umfangsbestimmung und ein Festpreis kommen danach.