A practical case study on moving from WordPress and Typo3 to headless Contentful, covering content modeling decisions, locale fallbacks, preview environments, and the mistakes we made along the way.
The platform had been running on a hybrid CMS stack for seven years: WordPress handling the marketing site, Typo3 managing the enterprise product documentation, and a custom PHP layer stitching them together behind a React frontend. It worked, until it didn't.
Three factors pushed the migration decision:
The goal was a single headless CMS, Contentful, serving all content through a clean API to a Next.js frontend with SSG and ISR.
The biggest mistake teams make with Contentful migrations is importing their old CMS structure directly. WordPress pages become Contentful pages. Typo3 records become Contentful entries. The result is a headless CMS with a headed CMS mindset.
Headless content modeling is about content, not presentation. Ask "what is this?" not "where does this appear?"
[WordPress Page]
├── hero_image
├── hero_title
├── hero_subtitle
├── body_content (HTML blob)
└── sidebar_widget_ids[]
[Campaign Entry]
├── title (Short text)
├── slug (Short text, unique)
├── summary (Short text, 200 chars)
├── body (Rich text)
├── hero (Reference → Media asset)
├── category (Reference → Category entry)
└── relatedCampaigns (Reference array → Campaign[])
[Category Entry]
├── name (Short text)
├── slug (Short text)
└── color (Short text)
The campaign entry has no concept of "sidebar" or "hero section layout". That's the frontend's concern. This separation let us reuse the same content entries for the web app, a mobile app, and an email campaign builder that launched six months later.
Contentful supports a pattern where you model reusable UI blocks as content entries: HeroBlock, TestimonialBlock, CTABlock. This feels powerful but creates tight coupling between content and presentation. Editors end up making layout decisions, and developers end up supporting infinite content type variations.
We used it selectively, only for genuinely standalone, reusable modules with their own editorial lifecycle (like a TeamMember or a PricingTier). Not for page layout.
We support German and English. Contentful's localization model has one concept worth understanding clearly: fallback locales.
When a field is not translated, Contentful can fall back to a default locale. We configured this:
de-DE → fallback → en-US
In practice this meant editors didn't block releases waiting for translations. The German site would show English content for new fields until the translation was ready. We surfaced this clearly in the editor UI with a custom annotation in the Contentful UI extension.
One gotcha: not all fields should fall back. The slug field must not fall back. A German page route should never accidentally resolve to an English slug. We marked slug fields as locale-required with validation rules.
// Contentful Management API — setting locale validation on slug fields
await environment.createContentType({
sys: { id: 'campaign' },
name: 'Campaign',
fields: [
{
id: 'slug',
name: 'Slug',
type: 'Symbol',
localized: true,
required: true,
validations: [{ unique: true }, { regexp: { pattern: '^[a-z0-9-]+$' } }],
},
// ...
],
});Contentful's Rich Text field stores content as a JSON document (similar to Slate.js AST). This is more portable than an HTML blob, but rendering it requires a client-side renderer.
We used @contentful/rich-text-react-renderer with custom node renderers for embedded entries:
import { documentToReactComponents } from '@contentful/rich-text-react-renderer';
import { BLOCKS, INLINES } from '@contentful/rich-text-types';
const richTextOptions = {
renderNode: {
[BLOCKS.EMBEDDED_ENTRY]: (node) => {
const entry = node.data.target;
if (entry.sys.contentType.sys.id === 'callout') {
return <Callout text={entry.fields.text} type={entry.fields.type} />;
}
return null;
},
[INLINES.HYPERLINK]: (node, children) => (
<a href={node.data.uri} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
},
};
function RichTextBody({ document }) {
return <div>{documentToReactComponents(document, richTextOptions)}</div>;
}The key decision: limit what editors can embed. We whitelisted only callout, codeBlock, and imageWithCaption as embeddable entry types. Anything more and the rich text becomes a layout tool again.
One blocker for editor adoption was the loss of the WordPress preview button. Editors needed to see unpublished content before publishing.
Next.js has native draft mode for this. We built a preview route that:
__previewData cookie via the Next.js draft mode API// app/api/preview/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
const locale = searchParams.get('locale') ?? 'en';
if (secret !== process.env.CONTENTFUL_PREVIEW_SECRET) {
return new Response('Invalid token', { status: 401 });
}
draftMode().enable();
redirect(`/${locale}/${slug}`);
}Editors now preview directly from the Contentful sidebar. Adoption went from "why did we migrate?" to "I don't want to go back" within two weeks.
We wrote a one-time migration script using the Contentful Management API and a WordPress XML export:
import contentful from 'contentful-management';
const client = contentful.createClient({ accessToken: process.env.MANAGEMENT_TOKEN });
const env = await client.getSpace(SPACE_ID).then(s => s.getEnvironment('master'));
for (const wpPost of wordpressExport) {
await env.createEntry('blogPost', {
fields: {
title: { 'en-US': wpPost.title, 'de-DE': wpPost.title_de ?? wpPost.title },
slug: { 'en-US': wpPost.slug, 'de-DE': wpPost.slug_de ?? wpPost.slug },
body: { 'en-US': htmlToRichText(wpPost.content) },
},
});
}htmlToRichText was the tricky piece, converting WordPress HTML into Contentful's Rich Text JSON. We used @contentful/rich-text-html-renderer in reverse with a custom parser. It handled 80% of content correctly; the remaining 20% we manually reviewed.
Model content types collaboratively with editors first. We built the initial content model without enough editor input. Two types had to be refactored after launch because they didn't reflect how editors think about content.
Set up Contentful environments before you need them. We ran migrations against master for the first two months. Setting up a staging environment early would have saved us three near-misses.
Audit Rich Text usage before migration. The HTML-to-Rich-Text conversion had sharp edges around custom shortcodes. Audit your legacy content early.
Six months after launch:
The migration took four months, longer than estimated. But the compounding benefit of a clean content model compounds every sprint after launch.