Webflow OG Images for CMS Collection Items

Give every Webflow CMS item its own Open Graph image: bind an Image field filled by a script, or embed a card URL in the collection page head.

By Simon Chiu · Updated

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

  1. In the Designer, open the collection's settings and add an Image field named OG image. Its slug is og-image.
  2. Open the collection template page, then Page settings → Open Graph settings, and bind Open Graph image to the OG image field.
  3. 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.

  1. Deploy a signing function that takes a path and returns the PNG, such as the api/og.ts file on the Vercel page. Its ALLOWED_PATH must match your collection URLs, for example /blog/<slug>.
  2. On the collection template page, leave the Open Graph image setting empty, so Webflow does not print a second og:image.
  3. In Page settings → Custom code → Inside <head> tag, add the tag below. Where it says SLUG, 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.

Frequently asked questions

Can Webflow generate an OG image for each CMS item?
Not by itself. Webflow can bind a collection page's Open Graph image to an Image field, so something has to fill that field. A script that renders a card per item and writes it through the Webflow API does that once per item.
Can I put a signed screenshot URL in a plain text CMS field?
It works for a while, then breaks. Signed URLs expire after at most 30 days, and a CMS field does not change by itself, so every old item would end up with a dead image. Store the image in an Image field, or embed a URL that is signed fresh on each request.
Why is the image missing on LinkedIn or Slack after I published?
Those platforms cache previews by page URL. Publish the item with its new image, then ask the platform to fetch the page again with its post inspector or debugger.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.