Rich Links for Lazy Developers
When I share a blog link on Twitter or Slack, it shows up as plain text. No preview image. Just a URL like granda.org/en/2026/01/02/claude-code-on-the-go/.
I needed Open Graph images. The standard approach: manually create a 1200x630 image for each post. That’s tedious. I asked Claude to automate it.
flowchart LR
Push[git push] --> GHA[GitHub Actions]
GHA --> Hugo[Hugo Server]
GHA --> PW[Playwright]
PW -->|screenshot| Hugo
PW --> IMG[OG Image]
IMG --> Commit[git commit]
Commit --> Deploy[Deploy]
flowchart TB
Push[git push] --> GHA[GitHub Actions]
GHA --> Hugo[Hugo Server]
GHA --> PW[Playwright]
PW -->|screenshot| Hugo
PW --> IMG[OG Image]
IMG --> Commit[git commit]
Commit --> Deploy[Deploy]
The Setup
I told Claude the problem: posts need social preview images, but I don’t want to create them manually. Screenshot the article content, save it as an OG image, update the frontmatter automatically.
What Claude Built
| Component | Purpose |
|---|---|
generate-og-image.js | Playwright script to screenshot posts |
generate-og-images.yml | GitHub Action triggered on post changes |
baseof.html changes | Conditional og:image meta tags |
The Playwright script launches headless Chrome, navigates to the post, forces light mode, hides the header and footer, and screenshots at 1200x630:
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1200, height: 630 },
deviceScaleFactor: 2, // Retina for crisp text
});
// Force light mode, hide nav
await page.evaluate(() => {
document.documentElement.setAttribute("data-theme", "light");
document.querySelector("header").style.display = "none";
document.querySelector("footer").style.display = "none";
});
await page.screenshot({ path: outputPath, type: "png" });
The GitHub Action triggers on any .en.md file change, starts Hugo, runs the script, and commits the generated image back to the repo:
on:
push:
branches: [main]
paths:
- "content/posts/**/*.md"
steps:
- name: Start Hugo server
run: hugo server --bind 0.0.0.0 --port 1313 &
- name: Generate OG images
run: node scripts/generate-og-image.js "$file"
- name: Commit changes
run: |
git add static/images/posts/ content/posts/
git commit -m "Generate Open Graph images for blog posts"
git push
The template conditionally includes the image when the frontmatter has an image field:
{{- if .Params.image -}}
<meta property="og:image" content="{{ $canonical }}{{ .Params.image }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="{{ $canonical }}{{ .Params.image }}">
{{- else -}}
<meta name="twitter:card" content="summary">
{{- end -}}
404: Slug Not Found
First run. Playwright screenshotted a blank page. The URL was returning 404.
The script had built the URL from the filename: automatic-blog-translations.en.md → /en/2025/12/23/automatic-blog-translations/
But Hugo doesn’t use the filename for slugs. It uses the title. The actual URL was /en/2025/12/23/automatic-blog-translations-with-claude-and-github-actions/.
I could have reimplemented Hugo’s slugify algorithm in JavaScript. Instead: ask Hugo.
const output = execSync("hugo list all", { encoding: "utf8" });
// CSV output includes the exact permalink Hugo generates
One line. No edge cases. Hugo already knows its own URLs.
What’s Next
The current implementation only generates images for English posts. Translated versions reference the same image path, which works, but it means the OG image always shows English text even when sharing a Spanish or Japanese version.
True i18n support is coming in the next PR. For now, every post gets a social image, which is better than none.
The Takeaway
This took 14 minutes to build and debug. Now every post automatically gets a social preview image. No manual work per post. The automation pays for itself after a handful of posts.
Ship the working version, discover the gaps, iterate.
Related posts:
- Automatic Blog Translations With Claude and GitHub Actions - same stack, different automation
- My QA Engineer is an LLM - letting Claude review the code it writes
- Rolling My Own Newsletter with Claude - another 30-minute build