Why a shared default image is a problem
A default card is fine for a homepage. On a blog post or docs page, it tells the reader nothing about the link. When we audited the New York Times social previews, the World section's og:image was the same promo image the homepage used. A shared World link looked like a homepage link.
The shared image is hard to spot by hand. You have to open the tags for each page, and a site with a few hundred pages is too many to paste into a debugger one by one.
Spot-check five pages first
For a quick look, paste your sitemap into the Bulk OG Checker. It checks the first five page URLs in the sitemap and shows each page's tags and image. If those five pages already show the same image, you know enough to start fixing the template.
The bulk checker does not group pages by image, and it stops at five. For the whole sitemap, run the script below on your own machine or in CI.
What the script does
The script reads sitemap.xml (a URL or a local file), checks each page with the free myog-checker CLI, and groups the pages by the og:image URL they declare. It reports:
- image URLs shared by two or more pages, with the pages listed
- pages with no
og:imageortwitter:image - pages whose image comes only from
twitter:image, which some platforms do not read - pages that returned an HTTP error, and checks that failed
The script compares declared URLs, not image files. It never downloads an image. Two different URLs can serve the same picture, one URL can serve different bytes over time, and a unique URL can still be broken. Use a page-level checker such as the OG preview tool when you need to see the image itself.
Install and run it
You need Node.js 22.19.0 or later, which is what myog-checker requires. No MyOG account or API key is needed; every request goes from your machine to your site.
mkdir og-audit && cd og-audit
npm install myog-checker@0.1.1 htmlparser2@10.0.0
curl -O https://myog.social/downloads/og-image-sitemap-audit.mjsOne MIT-licensed file, about 670 lines. It needs only the two packages above. Read it before you run it.
Then point it at a sitemap:
# A live sitemap
node og-image-sitemap-audit.mjs https://example.com/sitemap.xml
# A sitemap file your build wrote (the pages in it are still fetched live), at most 50 pages
node og-image-sitemap-audit.mjs ./dist/sitemap.xml --max-pages 50
# JSON for scripts, exit code 3 when pages share an image or have none
node og-image-sitemap-audit.mjs ./dist/sitemap.xml --json --strict Run it only against sites you own or are allowed to test. It checks one page at a time with a 500 ms pause between pages (--delay-ms changes it), and stops after 100 pages unless you raise --max-pages (up to 1,000).
Sample output
The output below comes from the script's offline test fixtures, which mock a small site on fixtures.example. The homepage, about page, and pricing page declare the same default card. One blog post has no image tags at all, and the pricing page only has twitter:image.
Pages checked on fixtures.example: 9
Sharing an image URL: 3
Shared image URLs: 1
With a unique image URL: 2
With no image URL: 1
Not compared (query-string image URLs): 2
HTTP errors: 1
Failed checks: 0
Shared by 3 pages: https://fixtures.example/images/default-card.png
https://fixtures.example/audit/
https://fixtures.example/audit/about
https://fixtures.example/audit/pricing
No og:image or twitter:image:
https://fixtures.example/audit/blog/second-post
Cannot compare (query string redacted by the checker), 2 pages: https://img.fixtures.example/og?REDACTED
https://fixtures.example/audit/docs/a
https://fixtures.example/audit/docs/b
Image from twitter:image only (no og:image tag):
https://fixtures.example/audit/pricing
HTTP errors (not grouped):
404 https://fixtures.example/audit/gone
Notes:
<loc> entries skipped (not public URLs on fixtures.example): 1
Image files were not fetched. A shared URL can still serve different bytes, and a unique URL can still be broken.How the script works
The script parses the sitemap with htmlparser2 in XML mode. It accepts a <urlset> or a <sitemapindex> root and rejects a truncated file. Extension entries such as <image:loc> and hreflang <xhtml:link> alternates are ignored, so only the page URLs in <loc> are checked.
The myog-checker CLI checks one URL per run, so the script starts one child process per page and reads the JSON it prints:
const child = spawn(process.execPath, [checkerCLI, "check", pageURL, "--json"], {
stdio: ["ignore", "pipe", "ignore"],
}) Each JSON report has metadata.imageURL, the effective image after the checker's fallbacks: Open Graph image tags first, then twitter:image. Relative URLs are already resolved against the final page URL, so /images/card.png and the absolute form group together. The grouping is a map keyed by that URL:
const { http, metadata, tags } = page.check.result
if (http.statusCode >= 400) {
httpErrors.push({ url: page.url, statusCode: http.statusCode })
continue
}
if (!metadata.imageURL) {
missing.push({ url: page.url })
continue
}
const entry = { url: page.url, source: imageSource(tags) }
const target = metadata.imageURL.endsWith("?REDACTED") ? queryRedacted : byImage
target.set(metadata.imageURL, [...(target.get(metadata.imageURL) ?? []), entry])The limits are in one LIMITS object at the top of the file:
- Only page URLs on one hostname are checked: the sitemap URL's host, or for a local file, the host of the first
<loc>. If your sitemap listswww.pages under a different host, pass--host. - A sitemap index is followed one level deep, up to 25 child sitemaps on the same host. Nested indexes are skipped and reported.
- Sitemap requests time out after 15 seconds, follow at most three same-host redirects, and stop at 10 MB. Gzip-compressed
.xml.gzfiles are rejected; pass the uncompressed XML. - Each checker run is killed after 20 seconds or 256 KB of output. The checker has its own 10-second deadline.
- Before each sitemap request, the script refuses
localhostand private IP addresses, and checks that the hostname does not resolve to one. That basic guard does not stop DNS rebinding. Page checks go through the checker's own public-address rules, which connect to the address they checked.
Read the results
A shared image on a few pages is often intentional. A homepage, pricing page, and legal pages can reasonably share the brand card. Look at which pages share it. If blog posts, docs pages, or product pages are in the group, the template is likely setting one image for everything.
Pages with no image URL usually come from a layout that never adds the tag, or from tags added by JavaScript after the page loads. The checker reads only the HTML the server returns, like most social crawlers. If your tags are added client-side, you need server rendering or prerendering first; the Vue prerendering guide shows one way to do that.
A twitter:image-only page has an image for X but no og:image tag. Add the Open Graph tag too; Facebook, LinkedIn, Slack, and most chat apps read og:image.
Query-string image URLs cannot be compared. The checker replaces query strings with ?REDACTED so reports do not leak tokens. Generators that put page data in the image URL, such as /api/og?title=Hello, look identical after redaction. The script lists those pages under "Cannot compare" instead of calling them shared.
A redacted URL used by only one page still counts as unique, because its path differs. Next.js opengraph-image URLs (for example /blog/hello-world/opengraph-image?869a…) fall in that case.
Failed checks show the checker's error code, such as timeout, blocked_url, or non_html. Sites that block unknown bots usually return 403, which shows under HTTP errors. The checker identifies itself as MyOGBot.
Run it in CI
With --strict, the script exits with code 3 when any page shares an image URL or has none. Exit code 1 means the sitemap or setup failed, and 2 means bad arguments. With the script committed to your repository, GitHub Actions steps against a public staging site look like this:
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm install myog-checker@0.1.1 htmlparser2@10.0.0
- run: node og-image-sitemap-audit.mjs https://staging.example.com/sitemap.xml --max-pages 200 --strict If a few pages should share the default card, --strict will fail on them too. In that case, use --json and filter the groups you expect in your own step.
Fix the pages, then run it again
The fix depends on where your tags come from:
- If your CMS has a featured or social image field, fill it in and check that the template uses it for
og:image. - If pages have no natural hero image, a fallback is still better as a per-page card with the page title than as one site-wide image.
- To generate an image for every page from code or a service, compare the options in how to generate OG images for every page: build-time scripts, request-time functions, hosted template APIs, and hosted URL-based generation.
MyOG is one of those options: one tag per page, and MyOG generates the image from the page's own title, description, and brand signals. It needs an account, your exact hostname registered, and available credits. The script and the checker are free and do not need any of that.
After the fix is deployed, run the script again and confirm the groups you expected are gone. Social platforms cache old previews, so use the cache guide if a shared link still shows the old card.