Open Graph cards fromheadless Chrome
The short answer
Write each card as an HTML string, load it in headless Chrome at 1200×630, and save the screenshot to public/og. Point each page's og:image at its card with an absolute URL. The cards are real HTML, so they use your own fonts and tokens, and they are static files, so nothing renders when a crawler visits.
Drive the cards from your data
This site imports the same project list its pages render, so a card's title, tag and cover always match its page. The cost is that a new project needs one more run of the script, or its page points at a card that does not exist.
const projects = useProjects();
const cards = {
home: home(),
projects: archive(projects),
...Object.fromEntries(projects.map((item) => [item.slug, project(item)])),
};Take the screenshot
Chrome can do this from the command line, so there is no browser-automation dependency to install. The virtual time budget gives web fonts and images time to arrive before the capture, and the file-access flag lets a local page read local covers.
execFileSync(chrome, [
"--headless=new",
"--disable-gpu",
"--hide-scrollbars",
"--force-device-scale-factor=1",
"--window-size=1200,630",
"--virtual-time-budget=6000",
"--allow-file-access-from-files",
`--screenshot=${path.join(out, `${key}.png`)}`,
pathToFileURL(html).href,
]);Design for the crop
Some messengers crop link previews to a square, so anything important belongs in the centre. These cards stack the headline over the subject and let the cover rise out of the bottom edge, which still reads when the sides are gone.
Save PNG rather than WebP, since not every crawler reads WebP, even if the rest of the site is WebP.
Declare the image properly
Crawlers do not resolve root-relative paths, so og:image has to be absolute. Declaring the type and dimensions as well lets a platform lay out the preview without downloading the image first.
{ property: "og:image", content: imageUrl },
{ property: "og:image:type", content: "image/png" },
{ property: "og:image:width", content: "1200" },
{ property: "og:image:height", content: "630" },
{ property: "og:image:alt", content: title },
{ name: "twitter:card", content: "summary_large_image" },