Photo of DeepakNess DeepakNess

Dynamic Open Graph Images for My 11ty Blog

Apart from adding webmentions and better handling images, I also recently finished setting up dynamic Open Graph image generation on my 11ty blog, and it is now working for blog posts, raw notes, and standalone pages.

Earlier I was using just one default social image for all pages and posts I had. It worked, but all shared links looked repetitive. So I created a local generation script and connected it into my existing workflow.

In this post, I will show the exact setup I am using.

1. How OG image is selected on my website

I use computed data to decide what image goes into og:image and twitter:image and have this file _data/eleventyComputed.js with the following content:

export default {
  ogImage: (data) => {
    // If the post has a custom image in frontmatter, use that
    if (data.image) {
      return data.image;
    }

    // For blog posts and raw notes, use the co-located og.jpeg
    if (data.page && data.page.url) {
      if (data.page.url.startsWith('/blog/') || data.page.url.startsWith('/raw/')) {
        return `${data.page.url}og.jpeg`;
      }
    }

    // Fall back to default metadata image
    return null;
  }
};

And in my base template, _includes/layouts/base.njk, both Open Graph and Twitter tags read this value:

OG value from base.njk

So the priority is:

  1. image from frontmatter (manual custom image)
  2. Generated co-located og.jpeg for /blog/* and /raw/*
  3. Site default fallback image from metadata

Final OG image URL value

And then the final Open Graph image URL looks something like how it looks in the above screenshot.

2. The generator script I use

I use sharp image library with a Node.js script at scripts/generate-og-images.js. And the script does the following:

  1. Scans content/blog and content/raw
  2. Scans top-level pages in content/
  3. Handles homepage as a special case
  4. Skips items that already have og.png, og.jpeg, or og.jpg
  5. Skips items that already have image in frontmatter
  6. Uses MD5 cache in .og-cache.json to skip unchanged items
  7. Outputs 1200x630 JPEG images

It uses the Vercel's satori library for layout, @resvg/resvg-js for rendering SVG to PNG, and sharp to produce final JPEG. Here are some useful snippets from the generate-og-images.js script.

const CACHE_FILE = path.join(rootDir, '.og-cache.json');
const BLOG_DIR = path.join(rootDir, 'content', 'blog');
const RAW_DIR = path.join(rootDir, 'content', 'raw');
const PAGES_OG_DIR = path.join(rootDir, 'public', 'img', 'og');
if (item.hasExistingOg || item.hasCustomImage) {
  skipped++;
  continue;
}

if (cache[cacheKey] === item.contentHash && fs.existsSync(outputPath)) {
  cached++;
  continue;
}
const outputPath = item.collection === 'pages'
  ? path.join(PAGES_OG_DIR, `${item.slug}.jpeg`)
  : path.join(item.folderPath, 'og.jpeg');

It's a huge 600 lines of script, so I didn't paste it all here. But you can click this link to view the entire script.

3. Card design setup

I kept the design clean and consistent:

  • White background with a subtle grid pattern
  • Blue accent strip at the top
  • Large post title and optional description
  • Footer with profile photo, name, and website label

The fonts are loaded from local files, the same ones that I am using on my website, so final images match the aesthetics of the website. By the way, here's the OG image for this post, the one you're currently reading:

OG image for this post

4. Commands and workflow

I just run the following command to generate images:

npm run og

In package.json, my local start flow already includes OG generation:

"start": "npm run optimize-images && npm run og && npx @11ty/eleventy --serve --quiet"

So in day-to-day writing, I can quickly regenerate social cards before publishing, and also optimize images on the go.

Terminal output after running npm run og script

And above is the terminal output when I run the script.

5. Useful edge case: manual override

If I set an image in frontmatter, generator skips that post on purpose. That lets me keep dynamic generation as default, and still use a custom OG design for specific posts.

Example:

image: "/blog/vivaldi-browser/og.jpg"

That is the full setup I am using right now.

It keeps social cards consistent without manual design work on every post, and cache makes regeneration fast when nothing changes. I will keep improving the design over time, but this setup already works well for my workflow.

Webmentions

What’s this?