Auto-Generating Social Images for Every Blog Post at Build Time
When someone shares your blog post on Twitter, LinkedIn, or Slack, the image that shows up next to the link is the OG image. Most people skip it entirely or fire up Canva for every single post.
I got tired of both options. So I built a system that generates one for every blog post, automatically, during the build process.
The Stack
Satori and sharp. Satori is a JSX-to-SVG renderer from Vercel. Sharp is an image processing library that converts SVG to PNG.
Together they let you create images with code, using the same typography and layout system as your site. No Canva, no manual exports, no "I'll add the image later" that turns into never.
The design itself is simple. Dark gradient background, thin accent-colored bar on the left, blog post title in Inter 800 weight, small site name at the bottom. I based it on an OG image generator I built for another project using Cloudflare Workers and a similar approach.
Font Handling
Satori needs actual font files, not Google Fonts URLs. You can't just point it at a CDN and have it work.
I cache the Inter 800 weight locally at .cache/Inter-800.woff. The first build downloads it, and subsequent builds reuse it from disk. The font file is about 40KB. Not worth fetching on every build.
How It Works
The script reads all blog post MDX files, pulls the title and slug from frontmatter, generates an SVG via Satori, then converts it to a 1200x630 PNG via sharp. Output goes to public/images/og/{slug}.png.
Since I use Next.js static export (output: "export"), there's no server-side image optimization. These are just static files served from public/, referenced in each blog post page's metadata.
The blog post page auto-references /images/og/{slug}.png when no featuredImage is set in frontmatter. For Twitter card metadata, I use summary_large_image for all blog posts. The OpenGraph and Twitter meta tags are set in generateMetadata.
Two Gotchas
First: Satori doesn't support width: "fit-content". This one cost me a while. Use "auto" instead. Satori has a specific subset of CSS it supports, and fit-content isn't in it.
Second: Next.js type-checks all TypeScript files during build, including scripts in your scripts/ directory. Satori's JSX object types don't satisfy ReactNode, so the build fails with type errors.
The fix is ugly but effective: // @ts-nocheck at the top of the script. It bypasses the type checker for that file only. Not ideal, but Satori's types aren't going to match React's expectations anytime soon.
Build Pipeline
The script runs first in the build pipeline, before next build:
npx tsx scripts/generate-og-images.ts && next build
If image generation fails, the build stops before producing a site with missing images. That's the right behavior. I'd rather have a failed build than a live site with blank social previews.
The full pipeline generates OG images, optimizes other images, builds the RSS feed, builds the sitemap, and then runs next build. All sequential, all at build time.
The Result
Every blog post gets a consistent, branded social image without me touching Canva. Write a new post, push to GitHub, image generates during build, site deploys with the image ready for sharing.
It's one of those things that feels like overkill to set up and then you wonder how you lived without it.
If you're building a consulting business and want help standing out, that's what I do. Get in touch.