

When I shared one of my posts on socials, the link preview was blank. No image, just the title and a gray box. I’d set a featured image in my CMS dashboard — it was clearly there — but social crawlers were ignoring it completely.
Turns out there were two separate bugs. They’re easy to miss because the site looks perfectly fine in a browser.
How Social Share Previews Work#
When you paste a URL into Twitter/X, iMessage, LinkedIn, or Slack, the platform’s crawler fetches that URL and reads the Open Graph meta tags in the <head>:
<meta property="og:image" content="https://example.com/image.webp" />
<meta property="twitter:image" content="https://example.com/image.webp" />htmlThe crawler then fetches the image at that URL and renders the preview card. Two things can silently break this:
- The URL isn’t a real HTTP URL — it’s a
data:URI (base64-encoded image embedded directly in the HTML) - The URL is technically a URL, but it points to
localhost— unreachable from the outside
Both give you the same result: no image in the preview. The crawler quietly fails and moves on.
How to Diagnose#
Before guessing, check what your page is actually serving. View source on the live page (Ctrl+U) and search for og:image:
<!-- Bug 1: base64 data URI — crawlers can't fetch this -->
<meta property="og:image" content="data:image/webp;base64,UklGRvpD..." />
<!-- Bug 2: localhost URL — unreachable from the internet -->
<meta property="og:image" content="http://localhost:4321/_astro/hero.C4SheoqF.webp" />
<!-- Correct -->
<meta property="og:image" content="https://yoursite.com/_astro/hero.C4SheoqF.webp" />htmlIf you’re seeing either of the first two, read on.
Bug 1: The Base64 Image#
This shows up when your CMS stores the hero image as a base64 data URI directly in the markdown frontmatter:
---
title: My Post
heroImage: data:image/webp;base64,UklGRvpDAABXRUJQVlA4...
---yamlThis works fine in the browser — the image renders — but when Astro processes it into an og:image tag, the full base64 string ends up as the content attribute. Social crawlers treat og:image as a URL to fetch. They won’t decode an embedded binary blob.
The Fix: Save Images as Real Files#
Extract the base64 data URI and write it to a real file on disk. In a Go backend, SavePost() is the right place to intercept it:
func SavePost(dir string, post models.Post) error {
// ... setup ...
// If heroImage is a base64 data URI, save it as a file instead
heroImage := post.HeroImage
if strings.HasPrefix(post.HeroImage, "data:") {
if path, err := saveHeroImageToDisk(postDir, post.HeroImage); err != nil {
fmt.Printf("Warning: could not save hero image for %s: %v\n", post.Slug, err)
} else {
heroImage = path
}
}
fm := frontmatterData{
// ...
HeroImage: heroImage, // now "./hero.webp" instead of "data:..."
}
}goThe saveHeroImageToDisk function parses the MIME type from the data URI, decodes the base64, and writes hero.webp (or .jpg, .png, etc.) into the post directory. The frontmatter ends up with:
heroImage: ./hero.webpyamlAstro’s content collection schema with image() picks up that relative path at build time, optimizes it, and outputs a proper /_astro/hero.{hash}.webp URL. That’s a real, crawlable HTTPS URL.
One more thing: your admin editor was probably uploading the image as base64 and expecting base64 back from the API. Now that the backend stores a file path, the GET /api/admin/posts/{slug} endpoint needs to read the file and return it as a data URI for the editor to display:
func (s *Server) GetPostAdminHandler(w http.ResponseWriter, r *http.Request) {
// ...
post, _ := fs.GetPost(s.ContentDir, slug)
// Convert file path back to base64 for the editor
if post.HeroImage != "" {
post.HeroImage, _ = fs.ReadHeroImageAsDataURI(s.ContentDir, slug, post.HeroImage)
}
respondJSON(w, http.StatusOK, post)
}goThe storage format (file on disk) is now separate from the API contract (base64 for the editor). Public readers get an optimized /_astro/ URL; the dashboard editor still sees the image it uploaded.
Bug 2: The Localhost URL#
This one is specific to Astro in SSR mode (output: 'server'). In SSR, Astro.url returns the internal server URL — the one Node.js sees, not the public domain:
Astro.url → http://localhost:4321/blog/my-postplaintextIf you build your og:image URL from Astro.url, you’re embedding localhost in every social share tag on every page:
---
// ❌ Wrong — Astro.url is localhost in SSR
const socialImageURL = new URL(ogImage, Astro.url).href
// result: http://localhost:4321/_astro/hero.C4SheoqF.webp
---astroThe Fix: Use Astro.site#
Astro gives you Astro.site, which is the canonical public URL you configured in astro.config.mjs. Build your canonical URL from that instead:
---
// ✅ Correct — canonicalURL built from Astro.site
const canonicalURL = new URL(Astro.url.pathname, Astro.site)
const socialImageURL = new URL(ogImage, canonicalURL).href
// result: https://yoursite.com/_astro/hero.C4SheoqF.webp
---astroThe same issue applies to any other URL you construct in BaseHead.astro — canonical links, og:url, twitter:url. All of them should come from canonicalURL, not Astro.url directly:
---
const canonicalURL = new URL(Astro.url.pathname, Astro.site)
const socialImageURL = new URL(ogImage ?? config.socialCard, canonicalURL).href
---
<link rel='canonical' href={canonicalURL} />
<meta content={canonicalURL} property='og:url' />
<meta content={socialImageURL} property='og:image' />
<meta content={socialImageURL} property='twitter:image' />astroVerifying the Fix#
After rebuilding, view source on the live page and look for og:image:
<meta content="https://yoursite.com/_astro/hero.C4SheoqF.webp" property="og:image" />htmlIf it’s a real https:// URL pointing to your domain, you’re done. You can also run it through social debugger tools — Twitter Card Validator, LinkedIn Post Inspector, or OpenGraph.xyz — though these cache aggressively and may show stale results for a while. Most have a “Scrape Again” button that forces a fresh fetch.
Gotchas#
| Problem | Cause | Fix |
|---|---|---|
| Image shows in browser, not in social preview | base64 data URI in og:image | Save image as real file, store relative path |
og:image URL contains localhost | Astro.url used in SSR mode | Use new URL(Astro.url.pathname, Astro.site) |
| Social debugger shows old/wrong image | Platform cache | Wait ~30 min, use “Scrape Again” |
| Editor shows broken image after backend change | Admin endpoint returning file path, not data URI | Convert back to base64 in admin handler |
Both Bugs at Once#
Worth noting: both bugs can exist simultaneously and compound each other. A data: URI embedded in a tag that’s also been constructed from localhost is doubly broken. Fix the localhost URL first, then check whether the image content itself is valid — the failure mode for the second bug is harder to see until the URL is actually well-formed.
In my case I had both at the same time. The page looked completely fine in the browser on production. The only symptom was a blank card when sharing a link — easy to ignore if you’re not actively testing it.