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:
So the priority is:
imagefrom frontmatter (manual custom image)- Generated co-located
og.jpegfor/blog/*and/raw/* - Site default fallback image from metadata
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:
- Scans
content/blogandcontent/raw - Scans top-level pages in
content/ - Handles homepage as a special case
- Skips items that already have
og.png,og.jpeg, orog.jpg - Skips items that already have
imagein frontmatter - Uses MD5 cache in
.og-cache.jsonto skip unchanged items - Outputs
1200x630JPEG 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:
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.
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