Here is a support message I have answered more than once: "I replaced the share image on the pricing page last week. Slack shows the new one. LinkedIn shows the new one after I asked it to re-read the page. But some other card still has the old picture, and so does the CDN when I open the image directly from another network."

The page was fine. The tags were fine. The problem was the file name. The site had a single og-pricing.png that the client overwrote whenever the design changed, and every layer between the server and the chat app had a reason to hold on to the old bytes for that exact URL: the CDN, the browser cache of whoever opened it, and the image proxies some apps put in front of share images.

The fix I now ship on static sites is boring and reliable: never change the bytes behind a share image URL. When the picture changes, the URL changes. This post builds a small Node script that does that automatically after your build, with tests, and is honest about what it does and does not fix.

What a content-hashed name actually solves

When a chat app builds a card it does two separate fetches. First it reads the page HTML and finds og:image. Then it fetches that image URL. Both results can be stored, and they are stored separately.

A content-hashed file name only helps with the second part. If the page HTML is fetched again, the app finds a URL it has never seen, such as og-pricing.3f9a1c07b2.png, and has to download it. There is no old copy to reuse because nothing has ever been stored under that name.

It does not force anyone to re-read the page. If an app stored the whole card for your page URL, it keeps showing that card until it fetches the page again. That is why the official refresh tools still matter: Facebook's Sharing Debugger and LinkedIn's Post Inspector ask those platforms to fetch the page again, and messages that were already sent in most chat apps keep the card they were sent with. The hashed name just makes sure that when a fresh fetch does happen, it cannot land on a stale image.

If your card shows no picture at all, this is the wrong tool. Start with the list of reasons an og:image does not show, which covers path-only URLs, blocked hosts, redirects and wrong content types. Come back here when the image works but refuses to change.

The rule the script follows

  1. Look at every built HTML file.
  2. Collect the og:image, og:image:url, og:image:secure_url and twitter:image values that point at your own site.
  3. For each of those files, hash the bytes and copy the file to name.<hash>.ext next to the original.
  4. Rewrite the tags on every page to the absolute URL of the hashed copy.
  5. Leave the original file in place, so cards that already point at the old URL keep loading.
  6. Leave images on other hosts alone and report tags that point at missing files instead of guessing.

Copying instead of renaming is deliberate. A card that someone shared last month still references og-pricing.png. Deleting it turns an outdated card into a broken one, which is worse.

The script also turns path-only values into absolute URLs while it is there. A path-only og:image is one of the most common reasons a card has no picture, because crawlers do not resolve it the way a browser does.

The script

It has no dependencies. It uses regular expressions on the head tags rather than a full HTML parser, which is fine for output from a build tool you control. If you run it on hand-edited HTML with unusual attribute quoting, swap in a parser.

// hash-share-images.mjs - give every local og:image / twitter:image a content-hashed file name // and rewrite the share tags in the built HTML to point at it. // Usage: node hash-share-images.mjs <build-dir> <site-origin> // e.g. node hash-share-images.mjs dist https://example.com import { createHash } from "node:crypto"; import { readFile, writeFile, copyFile, readdir, access } from "node:fs/promises"; import path from "node:path"; const TAG = /<meta\s+[^>]*?(?:property|name)=["'](og:image(?::url|:secure_url)?|twitter:image)["'][^>]*>/gi; const CONTENT = /content=["']([^"']+)["']/i; const HASHED = /\.[0-9a-f]{10}\.(png|jpe?g|webp|gif)$/i; export function hashedName(file, buf) { const h = createHash("sha256").update(buf).digest("hex").slice(0, 10); const ext = path.extname(file); return file.slice(0, -ext.length) + "." + h + ext; } // Map a tag value to a path inside the build dir, or null if it is not ours. export function localPath(value, origin) { let u; try { u = new URL(value, origin + "/"); } catch { return null; } if (u.origin !== new URL(origin).origin) return null; return decodeURIComponent(u.pathname); } export function rewriteHtml(html, origin, renamed) { let changed = 0; const out = html.replace(TAG, (tag) => { const m = tag.match(CONTENT); if (!m) return tag; const p = localPath(m[1], origin); if (!p || !renamed.has(p)) return tag; changed++; return tag.replace(CONTENT, `content="${new URL(renamed.get(p), origin).href}"`); }); return { html: out, changed }; } async function htmlFiles(dir) { const out = []; for (const e of await readdir(dir, { withFileTypes: true })) { const full = path.join(dir, e.name); if (e.isDirectory()) out.push(...(await htmlFiles(full))); else if (e.name.endsWith(".html")) out.push(full); } return out; } export async function run(dir, origin) { const pages = await htmlFiles(dir); const wanted = new Set(); for (const f of pages) { for (const tag of (await readFile(f, "utf8")).match(TAG) || []) { const m = tag.match(CONTENT); const p = m && localPath(m[1], origin); if (p && !HASHED.test(p)) wanted.add(p); } } const renamed = new Map(), missing = []; for (const p of wanted) { const src = path.join(dir, p); try { await access(src); } catch { missing.push(p); continue; } const target = hashedName(p, await readFile(src)); await copyFile(src, path.join(dir, target)); // keep the old file so old cards still load renamed.set(p, target); } let tags = 0; for (const f of pages) { const r = rewriteHtml(await readFile(f, "utf8"), origin, renamed); if (r.changed) { await writeFile(f, r.html); tags += r.changed; } } return { pages: pages.length, images: renamed.size, tags, missing, renamed: Object.fromEntries(renamed) }; } if (import.meta.url === `file://${process.argv[1]}`) { const [dir, origin] = process.argv.slice(2); if (!dir || !origin) { console.error("usage: node hash-share-images.mjs <build-dir> <site-origin>"); process.exit(2); } const r = await run(dir, origin.replace(/\/$/, "")); for (const [a, b] of Object.entries(r.renamed)) console.log(`${a} -> ${b}`); for (const m of r.missing) console.warn(`missing: ${m} (tag left as it was)`); console.log(`${r.pages} pages, ${r.images} images hashed, ${r.tags} tags rewritten`); if (r.missing.length) process.exitCode = 1; } 

A few details worth pointing out:

  • Ten hex characters of SHA-256 is plenty for a site's worth of share images. The name only has to change when the bytes change, and stay the same when they do not, so rebuilds are stable and caches stay warm.
  • localPath resolves values against your origin, so /img/share.png, img/share.png and https://example.com/img/share.png all map to the same file. Anything on another origin returns null and is skipped.
  • Already hashed names are skipped by the HASHED pattern, so running the step twice does not produce share.3f9a1c07b2.8d2e4a9b1c.png.
  • The exit code is 1 when a tag points at a missing file. In CI that stops a deploy with a share image path that has a typo, which is exactly the kind of thing nobody notices until a client pastes the link.

The tests

The tests use node:test and a temporary folder that looks like a tiny built site: a home page with an absolute image URL, a nested post with a path-only URL, and an about page whose image lives on another host.

import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtemp, mkdir, writeFile, readFile, readdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { run, hashedName, localPath } from "./hash-share-images.mjs"; const ORIGIN = "https://example.com"; const page = (img) => `<!doctype html><html><head> <meta property="og:image" content="${img}"> <meta name="twitter:image" content="${img}"> <meta property="og:title" content="Hello"> </head><body></body></html>`; async function site() { const dir = await mktemp(path.join(tmpdir(), "share-")); await mkdir(path.join(dir, "blog", "post"), { recursive: true }); await mkdir(path.join(dir, "img"), { recursive: true }); await writeFile(path.join(dir, "img", "share.png"), "first version"); await writeFile(path.join(dir, "index.html"), page(`${ORIGIN}/img/share.png`)); await writeFile(path.join(dir, "blog", "post", "index.html"), page("/img/share.png")); await writeFile(path.join(dir, "about.html"), page("https://cdn.other.net/a.png")); return dir; } test("same bytes give the same name, new bytes give a new name", () => { const a = hashedName("/img/share.png", Buffer.from("one")); assert.match(a, /^\/img\/share\.[0-9a-f]{10}\.png$/); assert.equal(a, hashedName("/img/share.png", Buffer.from("one"))); assert.notEqual(a, hashedName("/img/share.png", Buffer.from("two"))); }); test("only URLs on our own origin are treated as local files", () => { assert.equal(localPath("/img/a.png", ORIGIN), "/img/a.png"); assert.equal(localPath(`${ORIGIN}/img/a%20b.png`, ORIGIN), "/img/a b.png"); assert.equal(localPath("https://cdn.other.net/a.png", ORIGIN), null); }); test("rewrites og:image and twitter:image on every page, keeps the old file, leaves other hosts alone", async () => { const dir = await site(); const r = await run(dir, ORIGIN); assert.equal(r.images, 1); assert.equal(r.tags, 4); const files = await readdir(path.join(dir, "img")); assert.ok(files.includes("share.png")); const hashed = files.find((f) => /^share\.[0-9a-f]{10}\.png$/.test(f)); assert.ok(hashed); const post = await readFile(path.join(dir, "blog", "post", "index.html"), "utf8"); assert.ok(post.includes(`content="${ORIGIN}/img/${hashed}"`), "path-only value becomes absolute"); assert.ok((await readFile(path.join(dir, "about.html"), "utf8")).includes("https://cdn.other.net/a.png")); }); test("running twice changes nothing; a changed image gets a new name", async () => { const dir = await site(); await run(dir, ORIGIN); const before = await readFile(path.join(dir, "index.html"), "utf8"); const again = await run(dir, ORIGIN); assert.equal(again.tags, 0); assert.equal(await readFile(path.join(dir, "index.html"), "utf8"), before); const dir2 = await site(); await writeFile(path.join(dir2, "img", "share.png"), "second version"); await run(dir2, ORIGIN); const after = await readFile(path.join(dir2, "index.html"), "utf8"); assert.notEqual(after.match(/share\.[0-9a-f]{10}\.png/)[0], before.match(/share\.[0-9a-f]{10}\.png/)[0]); }); test("a tag that points at a missing file is reported and left as it was", async () => { const dir = await site(); await writeFile(path.join(dir, "gone.html"), page("/img/deleted.png")); const r = await run(dir, ORIGIN); assert.deepEqual(r.missing, ["/img/deleted.png"]); assert.ok((await readFile(path.join(dir, "gone.html"), "utf8")).includes('content="/img/deleted.png"')); }); 

Running them:

$ node --test hash-share-images.test.mjs ✔ same bytes give the same name, new bytes give a new name (1.777922ms) ✔ only URLs on our own origin are treated as local files (0.273374ms) ✔ rewrites og:image and twitter:image on every page, keeps the old file, leaves other hosts alone (12.011744ms) ✔ running twice changes nothing; a changed image gets a new name (6.444279ms) ✔ a tag that points at a missing file is reported and left as it was (6.483529ms) ℹ tests 5 ℹ suites 0 ℹ pass 5 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 91.686138 

The two tests I care about most are the idempotency test and the missing-file test. A build step that changes output on every run makes deploy diffs noisy and defeats caching. A build step that silently rewrites a tag to a file that does not exist is how you end up with cards that have no picture.

Wiring it into a build

For a plain static site, run it after the build and before the upload:

{ "scripts": { "build": "your-build-command", "postbuild": "node scripts/hash-share-images.mjs dist https://www.example.com" } } 

The origin argument has to be the public address people share, with the right www or no www, because it is written into every rewritten tag. If your preview deployments live on another host, pass that host for previews and the real one for production, or the preview pages will advertise production image URLs that do not exist yet.

Most frameworks already fingerprint files they import through the bundler. If your share image is imported in code, check the built HTML first: it may already have a hashed name. The case this script exists for is the common one where share images live in public/ or static/ and are copied as they are.

Caching headers once names are unique

Once a file name can only ever hold one version of an image, you can tell caches to keep it for a long time:

/img/*.png Cache-Control: public, max-age=31536000, immutable 

How you set that depends on the host. Keep it scoped to the hashed files. The original unhashed names should keep normal caching, because they still exist only for old cards.

Checking the result

After a deploy I check three things, in this order:

  1. Size of the new file. A redesign is the usual moment when a share image quietly grows into a heavy PNG. Run it through an OG image size check to see its pixel size and weight before you worry about anything else.
  2. The live tags. Paste the page URL into an Open Graph checker and confirm og:image now points at the hashed name, as an absolute https URL, and that the image loads.
  3. Platform caches. For Facebook and LinkedIn, ask their official tools to read the page again. For chat apps, send the link in a new message. Old messages keep their old cards and there is nothing a site can do about that.

What I would not do

  • Query strings like share.png?v=2. They often work, but some CDNs and proxies are configured to ignore query strings for static files, and you are back to one cache key with two images. A different path removes the question.
  • Overwriting in place and hoping. It is the cause of the support message at the top of this post.
  • Deleting old share images in the same deploy. Keep them at least as long as you would expect old links to circulate. Disk space for a few PNG files is cheap.

Summary

A share image is a public URL that other systems store. Treat it like any other long-lived asset: give each version its own name, keep the old ones loadable, and let your build do the renaming so nobody has to remember. The script above is about a hundred lines, has no dependencies, and turns "why is the old picture still there" into a question you only have to answer for caches you do not control.