Webflow can show a different Open Graph image for every CMS item, but only if the item has an image to show. Most blog collections have a thumbnail at best, cropped for the listing grid and not for a 1200×630 preview. This page shows two ways to give each item a real card: a script that renders a card per item and stores it in an Image field, and a head embed that points og:image at a small signing function. It is part of the integrations hub.
Status: written 2026-10-18. The script below ran under Node 22.18 against local stubs: one for the RenderScreenshot POST endpoint, with the documented JSON shape, and one for Webflow's v2 CMS items API as we understand it from Webflow's public reference. The run covered paging past 100 items, drafts, archived items, items that already had an image, a render failure and title escaping. It has not run against a live Webflow site, so the Webflow side, including how Webflow imports an image from a URL, is untested, and there are no Designer screenshots.
What you can build
- Cards for an existing blog. One script run fills the Open Graph image for every published post that has none.
- Cards for new posts. Run the same script on a schedule, for example in GitHub Actions each hour. It skips items that already have an image, so each run renders only new posts.
- Cards for any collection. Case studies, job listings or product pages work the same way; only the card template changes.
Two approaches
| Image field + script | Head embed + signing function | |
|---|---|---|
| Where the image lives | Webflow's asset storage | Your function's CDN |
| Runs when | Once per item | On the first crawler fetch, then cached |
| Needs | A Webflow API token, a scheduler | A Vercel or similar function |
| Good for | Fixed cards that never change | Cards that should follow edits |
Avoid a third idea, a plain text field that holds a signed screenshot URL. Signed URLs expire after at most 30 days (see signed URLs), and a CMS value does not change on its own, so old items would end up with broken previews.
Prerequisites
- A Webflow site with a CMS plan and a collection with a template page.
- For approach 1: a Webflow site API token with CMS read and write access, and a RenderScreenshot API key (
rs_live_...). Create a free account to get one. - For approach 2: a deployed signing function, such as the one on the Vercel integration page, and a signing key pair.
Approach 1: fill an Image field with a script
- In the Designer, open the collection's settings and add an Image field named
OG image. Its slug isog-image. - Open the collection template page, then Page settings → Open Graph settings, and bind Open Graph image to the
OG imagefield. - Run the script below with your token, collection ID and API key. It renders a card for each published item with an empty field, writes the image into the field, and publishes the changed items.
// webflow-og-images.mjs (Node 22+, no dependencies) // Renders a 1200x630 card for each published CMS item that has no OG image yet, // writes it into the item's Image field, and publishes the changed items. const RS = process.env.RS_API_BASE ?? 'https://api.renderscreenshot.com'; const WF = process.env.WEBFLOW_API_BASE ?? 'https://api.webflow.com/v2'; const { RS_API_KEY, WEBFLOW_TOKEN, COLLECTION_ID } = process.env; const FIELD = process.env.OG_FIELD ?? 'og-image'; // slug of the Image field const SITE_NAME = process.env.SITE_NAME ?? 'Example Blog'; // Titles are editor input placed into HTML: escape them. const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); const card = (item) => `<!doctype html><html><head><meta charset="utf-8"><style> body{margin:0;width:1200px;height:630px;box-sizing:border-box;padding:72px;display:flex; flex-direction:column;justify-content:space-between;font:28px/1.4 system-ui,sans-serif; background:#111827;color:#e5e7eb} h1{margin:0;font-size:64px;line-height:1.1;color:#fff} </style></head><body><h1>${esc(item.fieldData.name)}</h1><div>${esc(SITE_NAME)}</div></body></html>`; async function call(url, init, label) { const res = await fetch(url, { ...init, signal: AbortSignal.timeout(90_000) }); const body = res.status === 204 ? null : await res.json(); if (!res.ok) throw new Error(`${label}: ${res.status} ${JSON.stringify(body)}`); return body; } const webflow = (path, method = 'GET', body) => call(`${WF}${path}`, { method, headers: { Authorization: `Bearer ${WEBFLOW_TOKEN}`, 'Content-Type': 'application/json', Accept: 'application/json' }, body: body && JSON.stringify(body), }, `Webflow ${method} ${path}`); async function render(item) { const data = await call(`${RS}/v1/screenshot`, { method: 'POST', headers: { Authorization: `Bearer ${RS_API_KEY}`, 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ html: card(item), preset: 'og_card', cache: { available: true } }), }, `Render ${item.fieldData.slug}`); return data.image.url; // public CDN link, valid for the cache TTL (24 h by default) } const changed = []; for (let offset = 0; ; offset += 100) { const page = await webflow(`/collections/${COLLECTION_ID}/items?limit=100&offset=${offset}`); for (const item of page.items) { if (item.isDraft || item.isArchived || item.fieldData[FIELD]?.url) continue; try { const url = await render(item); // Webflow copies the file from this URL into its own asset storage. await webflow(`/collections/${COLLECTION_ID}/items/${item.id}`, 'PATCH', { fieldData: { [FIELD]: { url, alt: item.fieldData.name } }, }); changed.push(item.id); console.log('card set', item.fieldData.slug); } catch (err) { console.error('skipped', item.fieldData.slug, err.message); // the next run tries again process.exitCode = 1; } } if (offset + page.items.length >= page.pagination.total) break; } if (changed.length) { await webflow(`/collections/${COLLECTION_ID}/items/publish`, 'POST', { itemIds: changed }); console.log('published', changed.length, 'items'); }
WEBFLOW_TOKEN=... COLLECTION_ID=... RS_API_KEY=rs_live_... SITE_NAME="Acme Blog" \ node webflow-og-images.mjs
The card is HTML sent in the request, so the API does not need to reach your Webflow site, and password-protected or staging sites work too. The content input reference covers html, html_base_url for relative assets, and Markdown input. The script uses a Bearer key, not a signed URL: it runs on your machine or in CI, and signed URLs exist for links that the public sees.
The image.url link is public and lives for the cache TTL, 24 hours by default. That is long enough for Webflow to copy the file, and after that the copy in Webflow is the one your pages use.
Approach 2: embed a card URL in the page head
Use this when cards should change after an edit without running a script.
- Deploy a signing function that takes a path and returns the PNG, such as the
api/og.tsfile on the Vercel page. ItsALLOWED_PATHmust match your collection URLs, for example/blog/<slug>. - On the collection template page, leave the Open Graph image setting empty, so Webflow does not print a second
og:image. - In Page settings → Custom code → Inside
<head>tag, add the tag below. Where it saysSLUG, insert the item's Slug field with the field picker.
<meta property="og:image" content="https://cards.example.com/api/og?path=/blog/SLUG"> <meta property="og:image:width" content="1200"> <meta property="og:image:height" content="630">
The function signs a short-lived URL for each request, so nothing in Webflow ever expires. The signing key stays in the function, never in Webflow's custom code, which anyone can read in the page source.
Parameter mapping
| Script or embed | API parameter | Value |
|---|---|---|
| Card template | html |
Escaped title and site name |
| fixed | preset |
og_card, 1200×630 PNG (presets) |
| fixed | cache.available |
true: a rerun for an unchanged card costs 0 credits |
| Embed path | url (signed by the function) |
Your item's public URL |
Errors and retries
The script never stops on one bad item. A failed render or a failed Webflow update prints skipped <slug> with the HTTP status and JSON, sets a non-zero exit code so CI marks the run as failed, and leaves the field empty, so the next run tries that item again. A Webflow 429 means you sent too many API calls in a short time; run the script less often or add a pause between items. The error code list shows which RenderScreenshot errors are worth a retry.
Limits and costs
- Credits. Approach 1 uses one credit per item, once. Approach 2 uses one per card the first time a crawler fetches it, then the function's CDN serves it. See pricing.
- Webflow API. Webflow limits API requests per minute by plan, and the script makes about two calls per item plus one per 100 items listed.
- Image size. A 1200×630 PNG card is small next to Webflow's image upload limits. Switch the request to
output: { format: 'jpeg' }if a design with photos grows large.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Webflow 400 on PATCH |
The field slug is not og-image |
Set OG_FIELD to the real slug |
| The field is filled but previews show the old image | Items not published, or the platform cached the old preview | Check the publish step's output; re-scrape the page |
Two og:image tags |
Approach 2 with the Open Graph image setting still bound | Clear the setting on the template page |
| The card shows square boxes instead of text | Characters our fonts do not cover | Load a web font in the card's <style> |
401 from RenderScreenshot |
Key without the Bearer prefix, or a revoked key |
Check RS_API_KEY |
For the design of the card itself, sizes per platform and cache busting, read the OG image production guide. On Ghost, a theme partial and Admin API script fill the image field the same way.