We test our APIs, our components, and sometimes our CSS. Then a release goes out, marketing drops the new page into a Slack channel and a LinkedIn post, and the card shows last month's image with a title that ends in "| Home".
Link previews break silently. No error page, no failed request in your logs, nothing in Sentry. The first signal is usually a screenshot from someone outside the team. So I started treating the share card like any other contract and testing it after deploy.
This post walks through a small, dependency-free Node script that checks the Open Graph and Twitter Card tags on a list of production URLs, plus a GitHub Actions job that runs it after each deploy.
What's worth asserting
You don't need to test everything in the <head>. The failures I see most often are:
-
og:title,og:description, orog:imagemissing from the first HTML response (often because they're set client-side). -
og:imageis relative, points atlocalhostor a staging host, or doesn't return an image. - Duplicate
og:title/og:imagetags from two plugins or two layout levels. -
og:urlpointing at the wrong environment. -
twitter:cardmissing when you actually want the large image layout on X.
All of those are visible in raw HTML, which is exactly what crawlers read. Most social crawlers don't execute JavaScript, so the test shouldn't either. A plain fetch is closer to reality than a headless browser.
The script
Save this as scripts/check-previews.mjs. It uses Node 18+'s built-in fetch, so there's nothing to install.
// scripts/check-previews.mjs
const BASE = process.env.SITE_URL ?? "https://example.com";
const PATHS = ["/", "/pricing", "/blog/latest-post"];
const UA = "Mozilla/5.0 (compatible; PreviewSmokeTest/1.0)";
function metaAll(html, key) {
// Matches property="og:x" or name="twitter:x", either attribute order
const re = new RegExp(
`<meta[^>]+(?:property|name)=["']${key}["'][^>]*>`, "gi");
return [...html.matchAll(re)].map((m) => {
const c = m[0].match(/content=["']([^"']*)["']/i);
return c ? c[1] : "";
});
}
async function checkImage(url) {
const res = await fetch(url, { headers: { "User-Agent": UA }, redirect: "follow" });
const type = res.headers.get("content-type") ?? "";
const size = Number(res.headers.get("content-length") ?? 0);
return { ok: res.ok && type.startsWith("image/"), status: res.status, type, size };
}
async function checkPage(path) {
const url = new URL(path, BASE).toString();
const res = await fetch(url, { headers: { "User-Agent": UA } });
const html = await res.text();
const errors = [];
for (const key of ["og:title", "og:description", "og:image", "og:url"]) {
const values = metaAll(html, key);
if (values.length === 0) errors.push(`${key} missing`);
if (values.length > 1) errors.push(`${key} appears ${values.length} times`);
}
const [image] = metaAll(html, "og:image");
if (image) {
if (!image.startsWith("https://")) errors.push(`og:image not absolute https: ${image}`);
if (/localhost|staging|vercel\.app/.test(image)) errors.push(`og:image points at non-prod host: ${image}`);
else {
const img = await checkImage(new URL(image, url).toString());
if (!img.ok) errors.push(`og:image fetch failed (${img.status}, ${img.type})`);
if (img.size > 1_000_000) errors.push(`og:image is ${Math.round(img.size / 1024)} KB`);
}
}
const [ogUrl] = metaAll(html, "og:url");
if (ogUrl && !ogUrl.startsWith(BASE)) errors.push(`og:url is ${ogUrl}`);
if (metaAll(html, "twitter:card").length === 0) errors.push("twitter:card missing");
return { url, status: res.status, errors };
}
const results = await Promise.all(PATHS.map(checkPage));
let failed = 0;
for (const r of results) {
if (r.status !== 200 || r.errors.length) {
failed++;
console.error(`✗ ${r.url} (HTTP ${r.status})`);
r.errors.forEach((e) => console.error(` - ${e}`));
} else {
console.log(`✓ ${r.url}`);
}
}
process.exit(failed ? 1 : 0);
Run it locally against production:
SITE_URL=https://yoursite.com node scripts/check-previews.mjs
A few design choices worth calling out:
-
Regex instead of a DOM parser. For
<meta>tags in a server-rendered head this is good enough and keeps the script dependency-free. If your HTML is unusual (unquoted attributes, tags split across lines with odd spacing), swap innode-html-parserorcheerio. -
Duplicate detection. Two
og:imagetags is one of the most common real-world bugs, especially with CMS plugins or nested layouts. Crawlers pick one, and it's often not the one you want. -
The size check uses
content-length. Some servers don't send it for images; that's fine, the check just passes. Tighten it if you care. -
twitter:cardis enforced. X can fall back to Open Graph for title, description and image, but the card layout itself comes fromtwitter:card. If you wantsummary_large_image, say so. Delete that line if you don't post to X.
Running it after every deploy
The test has to hit the deployed site, so it belongs after your deploy step, not in the unit test job. Here's a minimal GitHub Actions workflow triggered after a successful deployment:
# .github/workflows/preview-smoke.yml
name: Link preview smoke test
on:
deployment_status:
workflow_dispatch:
jobs:
previews:
if: github.event_name == 'workflow_dispatch' || github.event.deployment_status.state == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: node scripts/check-previews.mjs
env:
SITE_URL: ${{ github.event.deployment_status.environment_url || 'https://yoursite.com' }}
Hosts like Vercel and Netlify emit deployment_status events, so this runs automatically for previews and production. For preview deployments, you may want to relax the "non-prod host" rule, since the image URL will legitimately point at the preview domain.
What the script won't tell you
An automated check proves the tags exist and the image loads. It doesn't tell you whether the card looks right: whether the headline gets cut off mid-word, whether the important part of the image survives a wide crop, whether the description reads like a sentence or a keyword list.
For that, I still eyeball the card before any launch that matters. I use OG Scope, a free checker I built for this. Paste the public URL and it fetches the HTML server-side (no JavaScript, same as the crawlers), then shows estimated cards for LinkedIn, WhatsApp, Facebook, X, Slack, Discord and Telegram, a metadata table, an image check, and a short checklist. No account needed. The cards are estimates, not the platforms' own renders, so for a stale cache you still go to Facebook's Sharing Debugger or LinkedIn's Post Inspector.
The split works well in practice: the script guards against regressions on every deploy, and a human looks at the card when the stakes are high.
Extending it
Ideas once the basic version is running:
- Read
PATHSfrom your sitemap so new pages get covered automatically. - Assert
og:titlelength (around 60 characters is a reasonable ceiling before truncation). - Check that
og:imagechanged when your hero image changed, to catch stale cached URLs. - Post failures to a Slack channel instead of only failing the job.
It's a tiny amount of code for a bug class that otherwise only gets reported by your users. Worth an afternoon.
Abid Ali is a web developer in Pakistan who builds small developer tools. He made OG Scope to check link previews before they go out.
Top comments (0)