Your content pipeline is already doing the hard work: titles, categories, author, publish date, sometimes a spicy pull-quote. Manually turning that into social cards is the part that feels like punishment. Automated social media image generation fixes that by treating images like build artifacts: deterministic, versionable, and produced the moment a post exists.
The clean architecture is simple: a blog post is created → a renderer API generates an image from a template → WordPress stores it in Media Library → the post’s Featured Image gets updated.
Do it right and you never touch Canva again, except when you want to, not because you must.
Social cards are mostly a layout problem, not a creativity problem. The creativity is in the template. Automation is in the mapping. Once you separate those two, you can ship consistent cards across LinkedIn, X, Facebook, Slack unfurls, Open Graph, even email headers, without humans copy-pasting titles at 1 a.m.
Also: templates enforce brand discipline. Humans do not.
Both tools follow the same mental model: you design a template once, you fill “layers” (text, images, colors) via API payloads, you get back a rendered image URL (or binary), you download it, you upload it to WordPress.
Where they differ in practice is less about “features” and more about ergonomics: how quickly you can iterate templates, how predictable their render queue is, and how cleanly they handle fonts, long titles, and multi-size variants.
A sane decision rule: if your bottleneck is template iteration and team collaboration, pick the one whose editor your designer doesn’t hate. If your bottleneck is reliability at scale, pick the one whose API status history makes you sleep.
The part most people underestimate is typography collision. Blog titles are chaotic. Your template is not. You need rules that turn chaos into a stable layout.
You’re mapping data fields into a constrained canvas. That means you should define a tiny spec like this: maximum characters per line, maximum lines, fallback shortening, optional subtitle injection, and a font-size strategy (either fixed or responsive).
Here’s a mapping model that behaves like production, not a demo.
| Blog field | Template layer | Transform rule | Failure mode it prevents |
|---|---|---|---|
post_title | title_text | smart wrap to 2–4 lines, then truncate with ellipsis | title spills off-canvas |
category | kicker_text | uppercase, max 24 chars | category badge overflows |
author | byline_text | optional, hide if missing | empty “By ” line |
featured_quote | sub_text | optional, max 90 chars | paragraph pretending to be a quote |
brand_color | accent_bar | default to brand hex if missing | random colors from chaos |
This is the unglamorous fix: wrap by words, cap the lines, then scale down if needed.
function wrapTitle(title, maxLineLen = 26, maxLines = 3) {
const words = title.trim().split(/\s+/);
const lines = [];
let cur = "";
for (const w of words) {
const next = cur ? `${cur} ${w}` : w;
if (next.length <= maxLineLen) {
cur = next;
} else {
if (cur) lines.push(cur);
cur = w;
if (lines.length === maxLines) break;
}
}
if (lines.length < maxLines && cur) lines.push(cur);
const usedWords = lines.join(" ").split(/\s+/).length;
const truncated = usedWords < words.length;
if (truncated) {
lines[lines.length - 1] = lines[lines.length - 1].replace(/\.*$/, "") + "…";
}
return lines.join("\n");
}
Now your template can render \n line breaks cleanly, and you’ve stopped long titles from destroying your brand identity.
A robust workflow typically looks like this:
The only “moving part” you need to choose is where the logic runs: inside WordPress (plugin) or outside (serverless). Outside is usually cleaner because you avoid PHP plugin entropy and you can reuse the same service for multiple sites.
This is the shape you want: a template ID plus “modifications”. The exact property names depend on the template you built in their editor; the principle is constant.
async function createBannerbearImage({ apiKey, templateId, title, category, imageUrl }) {
const res = await fetch("https://api.bannerbear.com/v2/images", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`
},
body: JSON.stringify({
template: templateId,
modifications: [
{ name: "title_text", text: title },
{ name: "kicker_text", text: category },
{ name: "bg_image", image_url: imageUrl }
]
})
});
if (!res.ok) throw new Error(`Bannerbear create failed: ${res.status}`);
return await res.json();
}
Rendering is often asynchronous. You create a job, then poll until image_url exists.
async function pollBannerbearImage({ apiKey, imageId, timeoutMs = 30000 }) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const res = await fetch(`https://api.bannerbear.com/v2/images/${imageId}`, {
headers: { "Authorization": `Bearer ${apiKey}` }
});
const data = await res.json();
if (data.image_url && data.status === "completed") return data.image_url;
await new Promise(r => setTimeout(r, 1200));
}
throw new Error("Bannerbear render timed out");
}
Same concept: a template plus layer values. You’ll typically provide layers (text/image) keyed by IDs from the template.
async function createPlacidImage({ apiKey, templateId, title, category }) {
const res = await fetch(`https://api.placid.app/api/rest/${templateId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`
},
body: JSON.stringify({
layers: {
title_text: title,
kicker_text: category
}
})
});
if (!res.ok) throw new Error(`Placid render failed: ${res.status}`);
const data = await res.json();
return data.image_url || data.url;
}
Whether you use Bannerbear or Placid, the integration pattern stays boring: build template once, map fields, render, upload.
You’re updating featured_media on the post. That requires two API calls: upload to /wp-json/wp/v2/media, then update the post at /wp-json/wp/v2/posts/{id}.
The simplest auth for a single-site automation is WordPress Application Passwords (fast to set up, good enough for internal tooling). If you’re running this across multiple sites or clients, you’ll eventually want OAuth or a signed internal proxy, but start simple.
async function wpUploadMedia({ wpBaseUrl, username, appPassword, filename, imageBuffer }) {
const auth = Buffer.from(`${username}:${appPassword}`).toString("base64");
const res = await fetch(`${wpBaseUrl}/wp-json/wp/v2/media`, {
method: "POST",
headers: {
"Authorization": `Basic ${auth}`,
"Content-Disposition": `attachment; filename="${filename}"`,
"Content-Type": "image/png"
},
body: imageBuffer
});
if (!res.ok) throw new Error(`WP media upload failed: ${res.status}`);
return await res.json();
}
async function wpSetFeaturedImage({ wpBaseUrl, username, appPassword, postId, mediaId }) {
const auth = Buffer.from(`${username}:${appPassword}`).toString("base64");
const res = await fetch(`${wpBaseUrl}/wp-json/wp/v2/posts/${postId}`, {
method: "POST",
headers: {
"Authorization": `Basic ${auth}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ featured_media: mediaId })
});
if (!res.ok) throw new Error(`WP post update failed: ${res.status}`);
return await res.json();
}
async function generateAndAttachSocialCard({
postId,
postTitle,
category,
wpBaseUrl,
wpUser,
wpAppPassword,
bannerbearKey,
bannerbearTemplateId
}) {
const title = wrapTitle(postTitle, 26, 3);
const job = await createBannerbearImage({
apiKey: bannerbearKey,
templateId: bannerbearTemplateId,
title,
category,
imageUrl: "https://example.com/static/bg.png"
});
const imageUrl = await pollBannerbearImage({ apiKey: bannerbearKey, imageId: job.id });
const imgRes = await fetch(imageUrl);
const imgBuf = Buffer.from(await imgRes.arrayBuffer());
const media = await wpUploadMedia({
wpBaseUrl,
username: wpUser,
appPassword: wpAppPassword,
filename: `social-card-${postId}.png`,
imageBuffer: imgBuf
});
return await wpSetFeaturedImage({
wpBaseUrl,
username: wpUser,
appPassword: wpAppPassword,
postId,
mediaId: media.id
});
}
This is the core. Everything “advanced” is just production hardening: retries, idempotency, caching, and handling updates without spamming your media library.
Template drift is real. Someone edits the template, renames a layer, your automation silently starts producing cards missing the title. The fix is to store a “template contract” in code: required layer names and a health check endpoint that renders a test card daily.
Duplicate media bloat is also real. If you regenerate a card on every minor edit, WordPress fills up with dead images. The fix is to store the last generated media ID in post meta and replace it, or delete prior media when you regenerate.
Title entropy will keep humiliating your layout. Add a second strategy: if wrap+truncate still looks bad, fall back to “short title + subtitle” using the first clause before a colon, or the first sentence.
I’d standardize on one template for Open Graph and one for “feed card”, and I’d generate both every publish. Even if you only use one today, you’ll eventually care when LinkedIn crops differently from X.
I’d also stop letting the social card be “just the title.” Add a short category kicker and a subtle site mark. The whole point is recognizability at scroll speed.
Most OCR automations fail because they OCR everything. Logos, signatures, random screenshots, someone’s cat. The…
We pulled 84,000 contact records from a client's CRM last month to feed into their…
The Triumphoid team is heading to Workflow 2026 on March 5, 2026 in San Francisco.…
Let me tell you about a Tuesday afternoon in March 2024. A client needed to…
Most automation workflows are fire-and-forget. An event happens, a sequence of steps executes, data moves…
Elizabeth Sramek from our team will be at WordCamp Madrid on March 6-7, 2026. Two…