Headless CMS + Image API: Automate Content Images

Headless CMS + Image API: Automate Content Images

You publish a blog post in Contentful. Or Sanity. Or Strapi. The content goes live, but something's missing: there's no featured image, no OG image, no social card. Someone on your team has to open a design tool, type the title onto a template, export it, and upload it back.

That workflow breaks at scale. Ten posts a month? Manageable. A hundred? Nobody's doing that by hand.

There's a better way. Wire your CMS to an image generation API with a webhook. Content gets published, an image gets created, and the URL gets written back to the CMS. Fully automatic, zero manual steps.

I'll walk through the exact setup for Contentful, Sanity, and Strapi, plus a generic pattern that works with any CMS that supports webhooks.

The pattern cms to webhook to image api to storeThe Pattern: CMS to Webhook to Image API to Store

Every integration in this guide follows the same flow:

  1. Content gets published in your headless CMS
  2. CMS fires a webhook to your endpoint (a serverless function)
  3. Your function extracts data: title, author, category, whatever you need
  4. Function calls the image API with that data and a template ID
  5. Image API returns a generated image URL
  6. Function writes the URL back to the CMS entry

The specifics differ per CMS (payload shapes, APIs for writing back), but the architecture is identical. Once you understand one, the others take minutes to set up.

Here's what you need before starting:

  • An Imejis account with a template designed for your blog
  • A serverless function host (Vercel, Netlify, or AWS Lambda)
  • Admin API keys for your CMS

Contentful integrationContentful Integration

Contentful is probably the most popular headless CMS out there. Its webhook system is solid and gives you fine-grained control over which events trigger requests.

Webhook setupWebhook Setup

In your Contentful space, go to Settings > Webhooks > Add Webhook.

Configure it like this:

  • URL: Your serverless function endpoint (e.g., https://your-app.vercel.app/api/contentful-image)
  • Triggers: Select only Entry.publish for the content type you want
  • Content type filter: Choose your blog post content type
  • Headers: Add a secret header for verification (e.g., X-Webhook-Secret: your-secret)

Only trigger on publish events. You don't want to generate images for draft saves, as that wastes API calls and creates images for unfinished content.

Webhook handlerWebhook Handler

Here's the serverless function that receives the Contentful webhook and generates an image:

// api/contentful-image.js
export default async function handler(req, res) {
  // Verify webhook secret
  if (
    req.headers["x-webhook-secret"] !== process.env.CONTENTFUL_WEBHOOK_SECRET
  ) {
    return res.status(401).json({ error: "Unauthorized" })
  }
 
  const { fields, sys } = req.body
  const entryId = sys.id
  const locale = "en-US" // adjust for your default locale
 
  const title = fields.title?.[locale]
  const category = fields.category?.[locale]
  const author = fields.author?.[locale]
 
  if (!title) {
    return res.status(400).json({ error: "No title found" })
  }
 
  // Generate image via Imejis API
  const imageResponse = await fetch("https://api.imejis.io/v1/images", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.IMEJIS_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      template_id: process.env.BLOG_TEMPLATE_ID,
      data: {
        title: title,
        category: category || "Blog",
        author: author || "Team",
      },
    }),
  })
 
  const imageData = await imageResponse.json()
  const imageUrl = imageData.url
 
  // Write image URL back to Contentful (next section)
  await updateContentfulEntry(entryId, imageUrl)
 
  return res.status(200).json({ success: true, imageUrl })
}

The payload structure from Contentful includes fields nested by locale. That's why you see fields.title?.['en-US'] instead of just fields.title. Keep your locale string in an environment variable if you support multiple languages.

Writing image url back to contentfulWriting Image URL Back to Contentful

After generating the image, you need to store the URL in the Contentful entry. This uses the Content Management API:

async function updateContentfulEntry(entryId, imageUrl) {
  const spaceId = process.env.CONTENTFUL_SPACE_ID
  const envId = process.env.CONTENTFUL_ENVIRONMENT_ID || "master"
 
  // Get current entry version (required for updates)
  const entryResponse = await fetch(
    `https://api.contentful.com/spaces/${spaceId}/environments/${envId}/entries/${entryId}`,
    {
      headers: {
        Authorization: `Bearer ${process.env.CONTENTFUL_MANAGEMENT_TOKEN}`,
      },
    }
  )
  const entry = await entryResponse.json()
  const version = entry.sys.version
 
  // Update the ogImage field
  entry.fields.ogImage = { "en-US": imageUrl }
 
  await fetch(
    `https://api.contentful.com/spaces/${spaceId}/environments/${envId}/entries/${entryId}`,
    {
      method: "PUT",
      headers: {
        Authorization: `Bearer ${process.env.CONTENTFUL_MANAGEMENT_TOKEN}`,
        "Content-Type": "application/vnd.contentful.management.v1+json",
        "X-Contentful-Version": version,
      },
      body: JSON.stringify({ fields: entry.fields }),
    }
  )
 
  // Re-publish the entry so the image URL goes live
  await fetch(
    `https://api.contentful.com/spaces/${spaceId}/environments/${envId}/entries/${entryId}/published`,
    {
      method: "PUT",
      headers: {
        Authorization: `Bearer ${process.env.CONTENTFUL_MANAGEMENT_TOKEN}`,
        "X-Contentful-Version": version + 1,
      },
    }
  )
}

Important: Re-publishing the entry will fire the webhook again. Add a guard clause at the top of your handler: if ogImage already exists, skip processing. Otherwise you'll create an infinite loop.

Sanity integrationSanity Integration

Sanity handles things differently. Instead of traditional webhooks, you can use GROQ-powered projections or Sanity's webhook configuration in the management dashboard.

Groq listener webhookGROQ Listener / Webhook

In the Sanity dashboard (API > Webhooks), create a new webhook:

  • URL: Your serverless function endpoint
  • Dataset: Production
  • Trigger on: Create, Update
  • Filter: _type == "post" && !(_id in path("drafts.**"))
  • Projection: {title, slug, category->{ title }, author->{ name }}

That filter is key. Sanity stores drafts with an _id prefixed by drafts.. By excluding those, you only fire the webhook when published documents change. The projection means Sanity sends you exactly the fields you need, no parsing a giant payload.

Handler patternHandler Pattern

The Sanity webhook payload is cleaner than Contentful's because of the projection:

// api/sanity-image.js
export default async function handler(req, res) {
  const secret = req.headers["sanity-webhook-secret"]
  if (secret !== process.env.SANITY_WEBHOOK_SECRET) {
    return res.status(401).json({ error: "Unauthorized" })
  }
 
  const { _id, title, slug, category, author } = req.body
 
  if (!title) {
    return res.status(400).json({ error: "Missing title" })
  }
 
  // Generate image
  const imageResponse = await fetch("https://api.imejis.io/v1/images", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.IMEJIS_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      template_id: process.env.BLOG_TEMPLATE_ID,
      data: {
        title: title,
        category: category?.title || "Blog",
        author: author?.name || "Team",
      },
    }),
  })
 
  const { url: imageUrl } = await imageResponse.json()
 
  // Write back to Sanity using mutations
  await fetch(
    `https://${process.env.SANITY_PROJECT_ID}.api.sanity.io/v2023-08-01/data/mutate/production`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.SANITY_API_TOKEN}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        mutations: [
          {
            patch: {
              id: _id,
              set: { ogImage: imageUrl },
            },
          },
        ],
      }),
    }
  )
 
  return res.status(200).json({ success: true, imageUrl })
}

Same infinite loop warning applies here. Check if ogImage is already set before generating a new one, or use a flag field like imageGenerated: true to prevent re-processing.

Strapi integrationStrapi Integration

Strapi takes a different approach. Since it's self-hosted (or cloud-hosted with Strapi Cloud), you can hook directly into the content lifecycle instead of using external webhooks.

Lifecycle hooksLifecycle Hooks

In Strapi v4+, lifecycle hooks let you run code when content is created or updated. This runs inside Strapi itself, so no separate serverless function is needed.

Create or edit the lifecycle file for your blog content type:

// src/api/blog-post/content-types/blog-post/lifecycles.js
module.exports = {
  async afterCreate(event) {
    await generateImage(event)
  },
 
  async afterUpdate(event) {
    const { data } = event.params
    // Only generate when published and no image exists
    if (data.publishedAt && !event.result.ogImage) {
      await generateImage(event)
    }
  },
}
 
async function generateImage(event) {
  const { result } = event
  const { id, title, category, author } = result
 
  if (!title) return
 
  try {
    const response = await fetch("https://api.imejis.io/v1/images", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.IMEJIS_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        template_id: process.env.BLOG_TEMPLATE_ID,
        data: {
          title: title,
          category: category?.name || "Blog",
          author: author?.name || "Team",
        },
      }),
    })
 
    const imageData = await response.json()
 
    // Update the entry with the image URL
    await strapi.entityService.update("api::blog-post.blog-post", id, {
      data: { ogImage: imageData.url },
    })
  } catch (error) {
    strapi.log.error("Image generation failed:", error)
  }
}

Custom controllerCustom Controller

If you'd rather trigger image generation on demand (say, from an admin button), you can add a custom controller:

// src/api/blog-post/controllers/generate-image.js
module.exports = {
  async generateImage(ctx) {
    const { id } = ctx.params
    const entry = await strapi.entityService.findOne(
      "api::blog-post.blog-post",
      id
    )
 
    if (!entry) {
      return ctx.notFound("Entry not found")
    }
 
    const response = await fetch("https://api.imejis.io/v1/images", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.IMEJIS_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        template_id: process.env.BLOG_TEMPLATE_ID,
        data: { title: entry.title },
      }),
    })
 
    const imageData = await response.json()
 
    const updated = await strapi.entityService.update(
      "api::blog-post.blog-post",
      id,
      {
        data: { ogImage: imageData.url },
      }
    )
 
    return ctx.send({ success: true, ogImage: updated.ogImage })
  },
}

Register the route in your Strapi routes file and call it from the admin panel or a custom plugin.

Generic webhook patternGeneric Webhook Pattern

Don't use Contentful, Sanity, or Strapi? No problem. If your CMS fires webhooks, this pattern works:

// api/cms-image-webhook.js
export default async function handler(req, res) {
  // 1. Verify the request (check signature, secret header, etc.)
  if (!verifyWebhook(req)) {
    return res.status(401).json({ error: "Unauthorized" })
  }
 
  // 2. Extract content data from the payload
  const { title, id, slug } = extractContentData(req.body)
 
  // 3. Generate the image
  const imageUrl = await generateImage(title)
 
  // 4. Store the URL back in the CMS
  await updateCmsEntry(id, imageUrl)
 
  return res.status(200).json({ success: true })
}
 
function extractContentData(payload) {
  // Adapt this to your CMS's webhook payload structure
  // Hygraph, Payload, Directus, KeystoneJS — each sends different shapes
  return {
    title: payload.data?.title || payload.entry?.title || payload.title,
    id: payload.data?.id || payload.entry?.id || payload.id,
    slug: payload.data?.slug || payload.entry?.slug || payload.slug,
  }
}
 
async function generateImage(title) {
  const response = await fetch("https://api.imejis.io/v1/images", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.IMEJIS_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      template_id: process.env.BLOG_TEMPLATE_ID,
      data: { title },
    }),
  })
 
  const data = await response.json()
  return data.url
}

The extractContentData function is where you handle the differences between CMS platforms. Everything else stays the same.

What to generateWhat to Generate

One content publish event can trigger multiple image types. You're not limited to a single image per post. From the same title, category, and author data, generate:

  • Blog thumbnail (16:9, used in post listings and feeds)
  • OG image (1200x630, shown when shared on Facebook, LinkedIn, Slack)
  • Twitter/X card (1200x675 or 800x418 for summary cards)
  • Email header (600px wide, for newsletter features)
  • Pinterest pin (1000x1500, vertical format)

Each image uses a different template ID but the same content data. If you want to learn more about generating OG images dynamically, I've written a full guide on that.

Call the API multiple times in parallel to generate all formats at once:

const templates = [
  { id: "tmpl_blog_thumb", field: "thumbnail" },
  { id: "tmpl_og_image", field: "ogImage" },
  { id: "tmpl_twitter_card", field: "twitterImage" },
]
 
const results = await Promise.all(
  templates.map(async (tmpl) => {
    const res = await fetch("https://api.imejis.io/v1/images", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.IMEJIS_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        template_id: tmpl.id,
        data: { title, category, author },
      }),
    })
    const data = await res.json()
    return { field: tmpl.field, url: data.url }
  })
)

This is similar to what you'd do when automating blog featured images, but triggered by CMS events instead of a standalone script.

Backfilling existing contentBackfilling Existing Content

Webhooks only handle new content. If you have hundreds of existing posts without images, you need a migration script. This is a one-time job: run it, backfill everything, and let webhooks take over for future posts.

// scripts/backfill-images.js
import Contentful from "contentful-management"
 
const client = Contentful.createClient({
  accessToken: process.env.CONTENTFUL_MANAGEMENT_TOKEN,
})
 
async function backfill() {
  const space = await client.getSpace(process.env.CONTENTFUL_SPACE_ID)
  const env = await space.getEnvironment("master")
 
  // Fetch all blog posts without an ogImage
  const entries = await env.getEntries({
    content_type: "blogPost",
    "fields.ogImage[exists]": false,
    limit: 100,
  })
 
  console.log(`Found ${entries.items.length} entries without images`)
 
  for (const entry of entries.items) {
    const title = entry.fields.title?.["en-US"]
    if (!title) continue
 
    const imageResponse = await fetch("https://api.imejis.io/v1/images", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.IMEJIS_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        template_id: process.env.BLOG_TEMPLATE_ID,
        data: { title },
      }),
    })
 
    const { url } = await imageResponse.json()
    entry.fields.ogImage = { "en-US": url }
 
    const updated = await entry.update()
    await updated.publish()
 
    console.log(`Generated image for: ${title}`)
 
    // Rate limit: wait between requests
    await new Promise((r) => setTimeout(r, 1000))
  }
}
 
backfill()

For really large datasets, check out the guide on batch image generation from CSV. The same batching techniques apply here.

Production tipsProduction Tips

Once your integration is working, there are a few things to tighten up before you trust it in production.

Idempotency. Your webhook handler will get called more than once for the same event. CMS platforms retry on timeouts, and some fire duplicate events. Before generating an image, check if one already exists for that entry. Skip if it does.

Error handling. Wrap your image generation call in a try/catch. If it fails, log the error and return a 200 to the CMS anyway. Returning an error code causes retries, which pile up fast. Handle failures in a queue or retry system you control.

Fallback images. Always have a default image configured in your frontend. If image generation fails for any reason, your posts should still display something. A branded placeholder is better than a broken image tag.

Monitoring. Log every webhook event with the entry ID and whether an image was generated. When something goes wrong (and it will eventually), you'll want to trace exactly what happened.

Webhook secrets. Every example above includes secret verification. Don't skip this. Without it, anyone can send fake payloads to your endpoint and run up your API usage.

If you're running this alongside a WordPress image automation setup too, keep your template IDs and API keys in a shared config to avoid duplication.

For sites built with Next.js and dynamic image generation, you can combine CMS webhooks with on-demand revalidation to keep everything in sync.


That's the full picture. Pick the CMS section that matches your stack, deploy the serverless function, and you'll never manually create a blog thumbnail again. The total setup time is about 30 minutes for one CMS, and every post from that point forward gets images automatically.

Ready to set up the templates your CMS will use? Sign up for Imejis and create your first image template in the visual editor. Then come back here and wire it up.

Frequently asked questionsFrequently Asked Questions

Which headless cms works best with image apisWhich headless CMS works best with image APIs?

Any CMS with webhooks works. Contentful, Sanity, Strapi, Hygraph, and Payload all fire webhooks on content changes. The image API doesn't care where the data comes from.

Do i need a separate server for thisDo I need a separate server for this?

Not necessarily. Serverless functions (Vercel, Netlify, AWS Lambda) handle webhooks perfectly. They spin up when the CMS fires an event and shut down after processing. No always-on server needed.

Can i generate images for existing contentCan I generate images for existing content?

Yes. Write a migration script that fetches all existing entries from your CMS API and generates images for each one. Run it once to backfill, then let webhooks handle new content going forward.

How do i store the generated image url back in the cmsHow do I store the generated image URL back in the CMS?

After generating the image, use your CMS's management API to update the entry with the image URL. Contentful has the Content Management API, Sanity has mutations, Strapi has REST endpoints.

What if the webhook fires before the content is readyWhat if the webhook fires before the content is ready?

Add a short delay or check for a "published" status in your webhook handler. Some CMS platforms fire webhooks on draft saves too. Filter for published entries only.