On most sites I build with a headless CMS, the link preview is the part nobody owns. Developers set up the <head> once, editors write content, and the share card is whatever falls out of the two. Then someone pastes a new article into LinkedIn and the card shows the site's default image, a title that's been cut off and a description that starts with "In this article, we will".
The fix isn't more meta tags. It's a better content model: give editors the right fields, make the fallbacks predictable, and show them the card before they publish. This post covers how I set that up, in a CMS-agnostic way, with TypeScript you can adapt to Sanity, Contentful, Strapi, Payload or anything else that returns JSON.
Start with a reusable "share" object
Most CMSs let you define a reusable object or component and attach it to several content types. I create one called share and add it to every type that gets its own URL: articles, landing pages, products, case studies.
// types/share.ts
export type CmsImage = {
url: string;
width: number;
height: number;
alt?: string;
focalPoint?: { x: number; y: number }; // 0..1, if the CMS supports it
};
export type ShareFields = {
title?: string; // optional override for the card headline
description?: string; // optional override for the card text
image?: CmsImage; // optional dedicated share image
};
Everything in it is optional, on purpose. Making share fields required sounds safe, but in practice editors fill them with a copy of the page title to get past validation, and you lose the signal that nobody actually thought about the card. Optional fields plus good fallbacks plus visible warnings works better.
A few rules I put in the CMS field descriptions, because that's the only documentation editors reliably read:
- Title: "Leave empty to use the page title. Fill it in when the page title only makes sense on the page itself (for example 'Overview')."
- Description: "One or two plain sentences about what the reader gets. No 'In this article'. Leave empty to use the excerpt."
- Image: "Landscape, at least 1200 px wide. Keep important text and faces away from the edges."
Most CMSs also support soft validation (a warning, not an error). Use it for length: warn when a share title goes past roughly 70 characters or a description past roughly 160. Those numbers aren't official limits for any platform, which is exactly why they should be warnings.
One resolver, one fallback chain
The most important piece is a single function that turns a CMS document into the values that end up in the <head>. Every template calls it. No template builds its own.
// lib/share.ts
import type { CmsImage, ShareFields } from "@/types/share";
type Doc = {
title: string;
excerpt?: string;
heroImage?: CmsImage;
share?: ShareFields;
section?: "blog" | "products" | "work";
};
const SITE_DEFAULT: CmsImage = {
url: "https://www.example.com/share/default.png",
width: 1200,
height: 630,
alt: "Example Studio logo on a dark blue background",
};
const SECTION_DEFAULTS: Partial<Record<NonNullable<Doc["section"]>, CmsImage>> = {
blog: { url: "https://www.example.com/share/blog.png", width: 1200, height: 630, alt: "Example Studio blog" },
};
function isUsableShareImage(img?: CmsImage): img is CmsImage {
if (!img) return false;
const landscape = img.width >= img.height * 1.3;
return img.width >= 1000 && landscape;
}
export type ResolvedShare = {
title: string;
description?: string;
image: CmsImage;
imageSource: "share" | "hero" | "section" | "site";
};
export function resolveShare(doc: Doc): ResolvedShare {
const title = doc.share?.title?.trim() || doc.title.trim();
const description = doc.share?.description?.trim() || doc.excerpt?.trim() || undefined;
if (doc.share?.image) {
return { title, description, image: doc.share.image, imageSource: "share" };
}
if (isUsableShareImage(doc.heroImage)) {
return { title, description, image: doc.heroImage, imageSource: "hero" };
}
const section = doc.section && SECTION_DEFAULTS[doc.section];
if (section) return { title, description, image: section, imageSource: "section" };
return { title, description, image: SITE_DEFAULT, imageSource: "site" };
}
A few decisions worth calling out:
A dedicated share image always wins, even if it's small. If an editor picked it, they meant it.
The hero image is only used if it's suitable. Hero images are often tall portraits, narrow banners or images with the important part on one side. A portrait photo used as a share image tends to get cropped to a strip of someone's forehead. The width and height come from the CMS asset metadata, which most headless CMSs return with every image, so this check costs nothing.
Section defaults beat the site default. A blog post with a "Blog" card at least tells people what they're about to open. Every link on the site showing the same logo card makes them all look like the homepage.
imageSource is returned on purpose. It's useful for debugging, and for the editor preview below.
Cropping with a focal point
If the hero image passes the check but isn't exactly 1.91:1, let the image CDN do the crop rather than shipping the original. Most image APIs can resize and crop on the fly, and several can crop around a focal point that editors set in the CMS. Parameter names differ between providers, so check yours. With imgix-style parameters it looks like this:
// lib/share-image-url.ts
export function shareImageUrl(img: CmsImage): string {
const u = new URL(img.url);
u.searchParams.set("w", "1200");
u.searchParams.set("h", "630");
u.searchParams.set("fit", "crop");
if (img.focalPoint) {
u.searchParams.set("crop", "focalpoint");
u.searchParams.set("fp-x", img.focalPoint.x.toFixed(3));
u.searchParams.set("fp-y", img.focalPoint.y.toFixed(3));
}
u.searchParams.set("fm", "jpg");
u.searchParams.set("q", "80");
return u.toString();
}
Asking for JPEG at a sensible quality keeps the file small, which matters because crawlers fetch the image on their own schedule and may give up on a slow or very large file. WebP works in most places now, but JPEG or PNG is still the safest fallback for older crawlers and less common apps, so that's what I default to for share images.
Wiring it into Next.js
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { getArticle } from "@/lib/cms";
import { resolveShare } from "@/lib/share";
import { shareImageUrl } from "@/lib/share-image-url";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const doc = await getArticle(slug);
const share = resolveShare(doc);
const image = {
url: shareImageUrl(share.image),
width: 1200,
height: 630,
alt: share.image.alt ?? share.title,
};
return {
title: doc.title,
description: share.description,
openGraph: {
title: share.title,
description: share.description,
url: `https://www.example.com/blog/${slug}`,
type: "article",
images: [image],
},
twitter: { card: "summary_large_image", title: share.title, images: [image] },
};
}
Notice that the page <title> stays the real document title, while the card uses the share override. They're allowed to differ, and that's the reason the override exists.
Show editors the card before they publish
The resolver is only half the value. The other half is that editors can finally see what their content will look like when shared. Most headless CMSs support a custom preview pane or a "preview URL". I add a small route that renders the card from the draft document using the same resolveShare function:
// app/(preview)/share-preview/[id]/page.tsx
import { getDraft } from "@/lib/cms";
import { resolveShare } from "@/lib/share";
import { shareImageUrl } from "@/lib/share-image-url";
export default async function SharePreview({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const s = resolveShare(await getDraft(id));
return (
<div style={{ maxWidth: 520, fontFamily: "system-ui", border: "1px solid #ddd", borderRadius: 8, overflow: "hidden" }}>
<img src={shareImageUrl(s.image)} alt="" style={{ width: "100%", aspectRatio: "1200 / 630", objectFit: "cover" }} />
<div style={{ padding: 12 }}>
<strong>{s.title}</strong>
{s.description && <p style={{ margin: "4px 0 0", color: "#555" }}>{s.description}</p>}
{s.imageSource !== "share" && (
<p style={{ color: "#b45309", fontSize: 13 }}>Using the {s.imageSource} image. Add a share image for a better card.</p>
)}
</div>
</div>
);
}
Protect this route like any other draft preview (the CMS's preview secret, or your existing auth). It's an approximation, not what LinkedIn or WhatsApp will render pixel for pixel, but it moves the "this card looks wrong" moment to before publishing, where it costs nothing.
After publishing: check the live page
The preview pane shows what your code intends. The live page shows what a crawler gets, after caching, redirects and whatever the deployment did. For pages that will go to a professional audience, I paste the live URL into OG Scope's LinkedIn preview, which reads the public HTML and lists the tags it found. If the image source in the card isn't the one you expected, the resolver's fallback order tells you exactly why.
One last habit: when an editor replaces a share image on an already-shared page, change the image URL too (a new asset ID usually does this automatically). Apps that cached the old card are more likely to pick up a new URL than a new file behind the same URL.
I'm Abid Ali, a freelance full stack developer in Karachi building with TypeScript, Next.js and headless CMSs. I made OG Scope, a free link preview checker.
About the author
- I'm Abid Ali, a full stack developer from Karachi with 3+ years of experience in TypeScript, Next.js, Gatsby, React and React Native. I build web apps, mobile apps and interactive UIs with solid API integration. Side projects: OG Scope (ogscope.pk), a free Open Graph checker; Digital Dialogue (digitaldialogue.pk), freelancing guides for Upwork and Fiverr beginners; and Meme Tap (memetap.pk), a free browser meme soundboard.
Comments
Join the discussionNo comments yet. Be the first to comment.



