DEV Community

Cover image for Add a Link Preview Smoke Test to Your Deploy Pipeline (Node Script + GitHub Actions)
Abid Ali
Abid Ali

Posted on

Add a Link Preview Smoke Test to Your Deploy Pipeline (Node Script + GitHub Actions)

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:

  1. og:title, og:description, or og:image missing from the first HTML response (often because they're set client-side).
  2. og:image is relative, points at localhost or a staging host, or doesn't return an image.
  3. Duplicate og:title / og:image tags from two plugins or two layout levels.
  4. og:url pointing at the wrong environment.
  5. twitter:card missing 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);
Enter fullscreen mode Exit fullscreen mode

Run it locally against production:

SITE_URL=https://yoursite.com node scripts/check-previews.mjs
Enter fullscreen mode Exit fullscreen mode

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 in node-html-parser or cheerio.
  • Duplicate detection. Two og:image tags 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:card is enforced. X can fall back to Open Graph for title, description and image, but the card layout itself comes from twitter:card. If you want summary_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' }}
Enter fullscreen mode Exit fullscreen mode

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 PATHS from your sitemap so new pages get covered automatically.
  • Assert og:title length (around 60 characters is a reasonable ceiling before truncation).
  • Check that og:image changed 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)