Linked preview image, preview image and lead image no longer showing as og:image in Volto

We've recently upgraded a few sites to Volto 18.32.1, and I've realised that the linked preview image, preview image and lead image no longer come up as og:image in the site's metadata.

Am I missing something? It used to just work, but perhaps we need to switch something on?

Anybody having same/similar issues?

The relevant code is in volto/packages/volto/src/components/theme/ContentMetadataTags/ContentMetadataTags.jsx at main · plone/volto · GitHub. It looks for the field configured in the contentMetadataTagsImageField setting (default: “image”). This code hasn’t changed recently. It would be a nice improvement if someone wanted to update this to support a list of multiple fields.

1 Like

Thanks for pointing me to the relevant code. That's very helpful context.

We've put together a customisation of ContentMetadataTags that implements a fallback chain for og:image:

  1. lead_image
  2. preview_image
  3. preview_image_link (linked image relation)
  4. image (generic fallback)

The trickiest part was preview_image_link. Depending on how the back-end serialises the relation, it may arrive as a fully expanded object with scales, or as a UID reference with only @id and no scale data. We handle both cases — if scales are present we validate against a minimum dimension threshold (600×315px, which is the practical floor for social platforms to pick up the image), and if it's unexpanded we construct a best-guess /@@images/image/large URL and accept we can't validate dimensions at that point.

We also added a minimum size check across the chain, which was prompted by wanting WhatsApp large previews to work reliably (our specific need). The console.warn logging is intentionally minimal — it only fires when something was expected but couldn't be resolved, not for content that simply has no images.

Still busy testing, so treat this as a working draft rather than a finished solution — but it's looking promising so far. Happy to hear if anyone spots edge cases we've missed.

Here's the full file:

/**
 * Customisation: ContentMetadataTags
 *
 * Drop into:
 *   src/customizations/volto/components/theme/ContentMetadataTags/ContentMetadataTags.jsx
 *
 * Adds an og:image fallback chain:
 *   1. lead_image
 *   2. preview_image
 *   3. preview_image_link (linked image relation)
 *   4. image (generic — e.g. Image content type)
 *
 * Images are validated against OG_IMAGE_MIN_WIDTH / OG_IMAGE_MIN_HEIGHT before
 * use. A console.warn fires only when something was expected but couldn't be
 * resolved — not for content that simply has no images.
 */

import React from 'react';
import { Helmet } from '@plone/volto/helpers';
import { flattenToAppURL, toBackendLang } from '@plone/volto/helpers';
import config from '@plone/volto/registry';

// Minimum dimensions for og:image to be picked up by social platforms.
// Facebook requires 200×200; 600×315 is the practical floor for decent previews.
const OG_IMAGE_MIN_WIDTH = 600;
const OG_IMAGE_MIN_HEIGHT = 315;

function getOgImageUrl(scalesObj, downloadUrl) {
  if (!scalesObj && !downloadUrl) return null;

  const preferredOrder = ['large', 'great', 'huge', 'preview'];

  for (const scaleName of preferredOrder) {
    const scale = scalesObj?.[scaleName];
    if (
      scale?.download &&
      scale.width >= OG_IMAGE_MIN_WIDTH &&
      scale.height >= OG_IMAGE_MIN_HEIGHT
    ) {
      return flattenToAppURL(scale.download);
    }
  }

  if (scalesObj) {
    const qualifying = Object.values(scalesObj).find(
      (s) =>
        s?.download &&
        s.width >= OG_IMAGE_MIN_WIDTH &&
        s.height >= OG_IMAGE_MIN_HEIGHT,
    );
    if (qualifying) return flattenToAppURL(qualifying.download);
  }

  if (scalesObj) {
    console.warn(
      '[ContentMetadataTags] Image found but no scale meets og:image minimums ' +
        `(${OG_IMAGE_MIN_WIDTH}×${OG_IMAGE_MIN_HEIGHT}px). Scales available:`,
      Object.entries(scalesObj).map(([k, v]) => `${k}: ${v?.width}×${v?.height}`),
    );
  }

  if (downloadUrl) return flattenToAppURL(downloadUrl);

  return null;
}

function resolveOgImage(content) {
  // 1. Lead image
  if (content.lead_image?.scales) {
    const url = getOgImageUrl(content.lead_image.scales, content.lead_image.download);
    if (url) return url;
  }

  // 2. Preview image (inline)
  if (content.preview_image?.scales) {
    const url = getOgImageUrl(
      content.preview_image.scales,
      content.preview_image.download,
    );
    if (url) return url;
  }

  // 3. Preview image link (linked image relation).
  if (content.preview_image_link) {
    if (content.preview_image_link.scales) {
      const url = getOgImageUrl(
        content.preview_image_link.scales,
        content.preview_image_link.download,
      );
      if (url) return url;
    } else if (content.preview_image_link['@id']) {
      // Relation present but not expanded — construct a best-guess URL.
      return flattenToAppURL(
        `${content.preview_image_link['@id']}/@@images/image/large`,
      );
    } else {
      console.warn(
        '[ContentMetadataTags] preview_image_link is present but has neither ' +
          'scales nor @id — cannot resolve og:image from it.',
        content.preview_image_link,
      );
    }
  }

  // 4. Generic image field
  if (content.image?.scales) {
    const url = getOgImageUrl(content.image.scales, content.image.download);
    if (url) return url;
  }

  return null;
}

const ContentMetadataTags = (props) => {
  const { content } = props;
  const { settings } = config;

  const ogImage = resolveOgImage(content);
  const lang = toBackendLang(content.language?.token || settings.defaultLanguage);

  return (
    <Helmet>
      <html lang={lang} />

      {content.title && <title>{content.title}</title>}
      {content.description && (
        <meta name="description" content={content.description} />
      )}

      {content.title && <meta property="og:title" content={content.title} />}
      {content.description && (
        <meta property="og:description" content={content.description} />
      )}
      <meta property="og:type" content="website" />
      {ogImage && <meta property="og:image" content={ogImage} />}

      <meta name="twitter:card" content="summary_large_image" />
      {content.title && <meta name="twitter:title" content={content.title} />}
      {content.description && (
        <meta name="twitter:description" content={content.description} />
      )}
      {ogImage && <meta name="twitter:image" content={ogImage} />}

      {content.exclude_from_nav && (
        <meta name="robots" content="noindex, follow" />
      )}
    </Helmet>
  );
};

export default ContentMetadataTags;

The point about updating core to support a list of fields rather than a single contentMetadataTagsImageField setting is a good one — a PR along those lines would be a cleaner long-term solution than everyone rolling their own customisation.