Every CMS wants your content in a different shape. WordPress wants HTML in a content field. Ghost wants Lexical or Mobiledoc, not raw HTML. Webflow wants a JSON object mapped to whatever fields your collection schema happens to have that week.
PostAll generates one clean content object per article. The interesting problem isn't generation — it's the last mile. Here's the adapter layer that turns that one object into three platform-specific POST requests, and the one gotcha per platform that will silently mangle your content if you don't handle it.
This assumes you've already got API credentials sorted for each platform (WP application passwords, a Ghost Admin API key, a Webflow site token). That part's in each platform's docs and it's not the interesting part anyway.
What you'll build
A single publish function that takes PostAll's output — title, body HTML, excerpt, tags, SEO metadata — and routes it through a per-platform adapter. Same input, three different wire formats out.
const postallOutput = {
title: "Why Your Node.js Event Loop Stalls Under Load",
bodyHtml: "<p>...</p><h2>...</h2><p>...</p>",
excerpt: "The event loop doesn't slow down gracefully. It falls off a cliff.",
tags: ["nodejs", "performance"],
seoTitle: "Node.js Event Loop Stalls: Causes and Fixes",
seoDescription: "...",
};
await publish(postallOutput, { platform: "wordpress", siteConfig });
Why this is harder than "just POST the JSON"
Each CMS's content API assumes its own editor produced the content. WordPress's Gutenberg-native posts expect block comments in the HTML if you want them to be editable as blocks later. Ghost's Admin API rejects raw HTML outright on newer API versions — it wants a Lexical document. Webflow's CMS API doesn't take HTML at all for rich-text fields; it wants an object with a specific shape, and your collection's field IDs, not field names.
None of this is documented in one place. You find it by shipping a post that looks fine in the API response and broken in the actual CMS.
WordPress: REST API, HTML straight through
WordPress is the easiest of the three because its REST API accepts raw HTML in the content field and just renders it — no block conversion required unless you want native Gutenberg blocks.
async function publishToWordPress(post, siteConfig) {
const response = await fetch(`${siteConfig.baseUrl}/wp-json/wp/v2/posts`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Basic ${siteConfig.authToken}`, // app password, base64
},
body: JSON.stringify({
title: post.title,
content: post.bodyHtml,
excerpt: post.excerpt,
status: "draft", // never auto-publish from an automation pipeline
meta: {
_yoast_wpseo_title: post.seoTitle,
_yoast_wpseo_metadesc: post.seoDescription,
},
}),
});
if (!response.ok) {
throw new Error(`WordPress publish failed: ${response.status}`);
}
return response.json();
}
The gotcha: that meta block for Yoast SEO fields only works if the site has Yoast's REST support enabled and the fields are registered with show_in_rest: true. If your SEO plugin isn't Yoast, the field names change entirely (RankMath uses a different meta key structure). Check this before you assume your SEO metadata is landing — the API will return 200 even if it silently drops fields it doesn't recognize.
Ghost: Admin API, and the HTML-to-Lexical problem
Ghost's Admin API authenticates with a short-lived JWT signed from your Admin API key, not a static bearer token. Beyond auth, the real difference from WordPress is the content format.
async function publishToGhost(post, siteConfig) {
const jwt = signGhostJWT(siteConfig.adminApiKey); // 5-minute expiry, sign per request
const response = await fetch(`${siteConfig.baseUrl}/ghost/api/admin/posts/?source=html`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Ghost ${jwt}`,
},
body: JSON.stringify({
posts: [
{
title: post.title,
html: post.bodyHtml,
custom_excerpt: post.excerpt,
status: "draft",
meta_title: post.seoTitle,
meta_description: post.seoDescription,
},
],
}),
});
if (!response.ok) {
throw new Error(`Ghost publish failed: ${response.status}`);
}
return response.json();
}
The gotcha: that ?source=html query param is doing all the work. Without it, the Admin API expects the lexical field with a serialized Lexical document, and it will reject plain HTML in html with a validation error that doesn't clearly say "you're missing a query param." I lost an hour to this the first time — the error message talks about the lexical field being required, which reads like you need to build a Lexical document from scratch. You don't. You just need the flag.
Webflow: CMS API v2, field IDs not field names
Webflow's CMS API is the most structurally different of the three because it's schema-driven. You're not posting to "a post" — you're posting an item into a specific Collection, and the item's fields have to match that collection's field slugs exactly.
async function publishToWebflow(post, siteConfig) {
const response = await fetch(
`https://api.webflow.com/v2/collections/${siteConfig.collectionId}/items`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${siteConfig.apiToken}`,
},
body: JSON.stringify({
isArchived: false,
isDraft: true,
fieldData: {
name: post.title,
slug: slugify(post.title),
"post-body": post.bodyHtml, // rich-text field slug, not "content"
"post-summary": post.excerpt,
},
}),
}
);
if (!response.ok) {
throw new Error(`Webflow publish failed: ${response.status}`);
}
return response.json();
}
The gotcha: fieldData keys have to match your collection's field slugs, and those slugs are generated from whatever your designer named the field in the Webflow UI — not from any convention you control. post-body in the example above is a slug I made up for illustration; yours might be body-content or main-copy depending on how the collection was set up. Pull the collection schema via GET /v2/collections/{id} first and map against the real slugs, or every publish will succeed with a 200 and populate nothing.
Also: creating an item doesn't publish your site. Webflow's CMS is decoupled from the live site until you call the publish endpoint separately or someone clicks "Publish" in the Designer. If your content isn't showing up live, this is almost always why.
What can go wrong (beyond the three gotchas above)
Rate limits hit differently per platform. WordPress has no built-in rate limit unless a security plugin adds one. Ghost's Admin API doesn't publish official limits but will 429 you under sustained bulk publishing. Webflow caps at 60 requests/minute on most plans — if you're batch-publishing more than a handful of articles, you need a queue with backoff, not a for loop.
Draft status doesn't mean the same thing everywhere. WordPress status: "draft" is unambiguous. Ghost's draft posts are excluded from the public API by default, which is fine. Webflow's isDraft: true items still count against your CMS item limit on metered plans — drafts aren't free.
HTML that renders fine in a browser preview can still break platform-specific rendering. Nested lists inside table cells render fine in raw HTML but Ghost's card-based renderer sometimes flattens them. Always spot-check the actual published (or draft preview) page, not just the API response.
Extending this
The adapter pattern above generalizes to any CMS with a documented content API — Contentful, Sanity, Strapi, all follow some version of "map generic fields to platform-specific shape." The part that doesn't generalize is the platform quirk, and that's the part you can only find by actually publishing something and watching it break.
I built this adapter layer for PostAll's multi-platform publishing, but the pattern's the useful part, not the specific field mappings — yours will differ the moment your Webflow collection has a different schema than mine.
Which CMS gave you the worst surprise the first time you automated publishing to it? I'd bet it wasn't the one you expected.
Top comments (1)
This looks like a time-saving tool! ⏱️
As a frontend developer, I'm always looking for ways to streamline content publishing across different platforms.
Has anyone tried this with a headless CMS setup? Would love to know if it works well with custom implementations.
Thanks for sharing, Aakash! 🙌