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:
lead_image
preview_image
preview_image_link (linked image relation)
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.