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

ComponentPurpose
generate-og-image.jsPlaywright script to screenshot posts
generate-og-images.ymlGitHub Action triggered on post changes
baseof.html changesConditional 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: