Skip to main content
  1. Posts/

My og:image URLs were broken for months. baseURL was the culprit.

··669 words·4 mins·
Nick Liu
Author
Nick Liu
Building infrastructure for Facebook Feed Ranking at Meta. Previously at Walmart, Twitter, AWS, and eBay. MS in Computer Science at Georgia Tech.
Table of Contents
Paste one of my post links into a social preview and the card comes up with no image. The site itself renders fine, every page, every browser. The culprit was one character in `hugo.toml`: `baseURL = "/"`, which quietly turns every absolute URL the site emits into a relative one that only a browser can love.
Tested with Hugo 0.163.3 · Blowfish 2.104

The symptom
#

Share cards without images, that was the visible part. View source on any page and the metadata told the fuller story:

<link rel="canonical" href="/">
<meta property="og:image" content="/img/og-default.png">

Both relative. The Open Graph spec wants og:image to be a full URL, and social scrapers do not resolve relative paths against the page they happen to be scraping. To LinkedIn or Slack, that image simply does not exist. Same for rel="canonical": a canonical of / tells a crawler nothing about which site this content belongs to.

Nothing in day-to-day use surfaces this. Browsers resolve relative URLs without complaint, so the site looked healthy for months while every machine-facing tag it emitted was useless.

Investigation
#

The config had:

# config/_default/hugo.toml
baseURL = "/"

Hugo’s absURL builds absolute URLs by joining against baseURL. Join against "/" and you get strings that still start with /. Internal navigation never noticed because Blowfish builds its links relatively. The breakage was confined to exactly the outputs I never looked at: canonical tags, Open Graph metadata, the RSS feed’s self link, and every <loc> in the sitemap.

Digging further turned up a second, related fossil: a hand-written static/robots.txt pointing crawlers at https://nickboy.github.io/sitemap.xml. Blowfish ships a robots.txt template that derives the sitemap URL from baseURL, but in this repo the static file is what ended up in the build output, hardcoded domain and all.

And it was hardcoded to the wrong domain, because this site answers on two.

Root cause: two domains, both claiming to be canonical
#

The primary deployment is Cloudflare Pages behind nick-liu.com. A GitHub Actions workflow also publishes the same content to GitHub Pages at nickboy.github.io as a backup. That workflow built with its own override:

hugo \
  --gc \
  --minify \
  --baseURL "${{ steps.pages.outputs.base_url }}/"

So the mirror’s pages carried canonicals pointing at the mirror. Two hostnames serving identical content, each declaring itself the original. Google’s guidance on duplicate content is direct about this setup: pick one canonical URL per piece of content and make every duplicate point to it. A backup that self-canonicalizes competes with the primary for ranking instead of backing it up.

The fix
#

Three changes, all small:

# config/_default/hugo.toml
baseURL = "https://nick-liu.com/"

Then delete static/robots.txt entirely. With the real domain in baseURL, the theme’s template generates the correct file on its own:

User-agent: *
Allow: /
Sitemap: https://nick-liu.com/sitemap.xml

Finally, drop the --baseURL override from the GitHub Pages workflow. The mirror now builds with the same config as the primary, so its canonical, og:image, RSS, and sitemap URLs all point at nick-liu.com. Navigation on the mirror still works because internal links are relative. The only absolute URLs are the machine-facing ones, and those now agree about who the real site is.

One loose end I checked before shipping: with an absolute baseURL, Cloudflare Pages preview deployments also emit nick-liu.com canonicals. That is fine. Cloudflare serves previews with an X-Robots-Tag: noindex header, so they never enter the index in the first place.

Lessons
#

  • baseURL = "/" breaks exactly the tags you never look at: canonical, og:image, RSS, sitemap. The browser hides the damage; crawlers see all of it.
  • A mirror that sets its own canonical is not a backup. It is a competitor.
  • A file in static/ can silently shadow a smarter template your theme already ships. Check what actually landed in public/ before writing your own.
  • Verify metadata from the consumer’s side. One paste into a social share debugger would have caught this on day one.

References
#

Related

My blog publishes one post a day. I haven't touched the deploy in weeks.

··1104 words·6 mins
In July I went three weeks without opening my blog repo. During those weeks it published two posts, on schedule, each one confirmed live by an automated check, and the only reason I know all this is a green history in the Actions tab. The system's single notification channel is a failure email, and it has never fired. This post is the full pipeline, including the parts that only exist because something went wrong on the way here. 🧪 Tested with Hugo 0.164.0 · Cloudflare Pages The one Hugo fact everything hangs on # Hugo skips content dated in the future unless you pass --buildFuture (docs). That single default turns the date field into a release valve. Merge a post dated next Tuesday and production simply does not contain it: not in the sitemap, not in RSS, not at its URL. It sits in main, invisible, until a build happens after its date.

My diagrams rendered on refresh and vanished on click. The head never loaded.

··1010 words·5 mins
A reader clicking from my homepage to a post with a diagram got a block of raw mermaid source. The same reader pasting that post's URL directly got a rendered diagram. Same page, same build, same browser. The difference was the click, and the bug had been live on this site for months across every diagram, every math formula, and every chart, because I had only ever tested pages by loading them directly. 🧪 Tested with Blowfish 2.10x · htmx 2.0.10 Two features, both reasonable, one collision # This site has htmx’s hx-boost on the body: internal navigation swaps page content in place instead of doing full page loads, which keeps transitions smooth. Separately, the Blowfish theme is smart about heavy libraries: mermaid, KaTeX, and Chart.js bundles are only included on pages that use them, injected into the <head> of exactly those pages.

Blowfish supports four analytics providers. Cloudflare Web Analytics isn't one.

··862 words·5 mins
For six months I assumed nobody could tell whether anyone read this blog, because I had never added analytics. Wiring up Cloudflare Web Analytics by hand taught me two things: the obvious place to paste the snippet would have shipped my Playwright suite's page views into the dashboard, and the dashboard had been quietly counting my visitors for two months anyway. 🧪 Tested with Hugo 0.163.3 · Blowfish 2.104 Publishing into the void # The site’s hugo.toml had a googleAnalytics line commented out since roughly the first commit. I never uncommented it. GA4 wants a cookie disclosure, ships a chunky client, and ad blockers eat it anyway, which felt like a lot of ceremony for a personal blog whose one open question was “does anybody visit.”