BACK TO ENGINEERING
Frontend 19 min read

480 Unique OG Images at Build Time: How We Made Every Page Shareable with Satori and resvg

Article Hero

You share a link on Twitter. It renders as a plain URL — no image, no title card, no description. Just a blue hyperlink that nobody clicks.

Your competitor shares a link. It renders with a rich card — a branded image, a compelling title, a description that hooks the reader. They get 3x more clicks.

The difference is a 1200x630 pixel PNG file. That's it. An Open Graph image. And most developers either skip it entirely or use the same generic image for every page.

When we built the mentoring platform at mentoring.oakoliver.com, we decided that every single page would have a unique, branded OG image. Not a template with swapped text. A genuinely unique visual composition — different colors, different layouts, different data — for every mentor profile, every locale, every route.

480 unique images. Generated at build time. Zero runtime cost.

Here's how.


I – The Scale of the Problem

Let me quantify what "every page" means for our platform.

The mentoring platform supports 40 locales. Each locale has 12 routes: homepage, mentor listing, individual mentor profiles, about page, FAQ, pricing, session booking, and several more.

40 locales times 12 routes equals 480 pages. Each page has a locale-specific title, locale-specific description, and locale-specific content. Each page needs an OG image that reflects its specific content.

You can't solve this with a single template that swaps the title text. A French mentor profile card needs French text, French date formatting, and a layout that accommodates longer French words (French text is typically 15-20% longer than English). A Japanese page needs different fonts entirely. A right-to-left Arabic locale needs mirrored layout.

The naive approach — designing 480 images in Figma — would take weeks. And every time we add a locale, mentor, or route, we'd need to create more images manually.

The alternative: generate them programmatically. Define the design once, parameterize it, and let code produce every variation.


II – Why Build Time, Not Runtime

Most dynamic OG image solutions run at request time. Vercel's OG image service, Cloudinary's dynamic transformations, even custom serverless functions that render on demand.

We rejected runtime generation for three reasons.

First, latency. Social media crawlers (Twitter's card bot, Facebook's scraper, LinkedIn's unfurl) have aggressive timeouts. Twitter's bot waits about 5 seconds for an OG image response. If your runtime rendering takes 3 seconds (not uncommon for complex images), you're flirting with timeout. If your server is under load and rendering takes 6 seconds, the crawler gives up and your link renders naked.

Second, cost. Image rendering is CPU-intensive. Every time a crawler hits a page, you burn CPU cycles to render an image. Popular pages get crawled by multiple services — Twitter, Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp. That's 6-7 renders for a single share event. Multiply by traffic. The compute costs add up.

Third, consistency. A runtime-generated image depends on the server state at the moment of rendering. If the server is mid-deployment, the image might reflect the old version of the page. If a database query is slow, the image might time out. Build-time generation eliminates these variables — the image is a static file, always available, always consistent.

Our SSG pipeline already generates 480 HTML pages at build time. Adding 480 OG images to the same pipeline is a natural extension. The images are generated alongside the HTML, deployed as static files, and served by Traefik with aggressive caching. No runtime computation. No timeouts. No inconsistency.


III – The Tool Chain: Satori and resvg-js

Two tools make this possible.

Satori is an open-source library from Vercel that converts a React-like JSX component into an SVG string. You describe the image using a subset of CSS (flexbox layout, fonts, colors, borders, shadows), and Satori produces a pixel-perfect SVG rendering.

Think of it as a headless browser that only renders one div. No DOM, no JavaScript execution, no network requests. Just layout computation and SVG generation. It runs in any JavaScript runtime — Node, Bun, Deno, Cloudflare Workers.

resvg-js is a WebAssembly-based SVG-to-PNG converter built on Rust's resvg library. It takes an SVG string and produces a PNG buffer. It's fast (typically 50-100ms per image), accurate (handles complex SVG features), and doesn't require a system-level dependency like Cairo or Inkscape.

The pipeline is simple:

  1. Build the image as a JSX component (using Satori's subset of HTML/CSS)
  2. Satori converts JSX to SVG (typically 20-60ms)
  3. resvg-js converts SVG to PNG (typically 50-100ms)
  4. Write the PNG to disk

Total per image: 70-160ms. For 480 images: about 50-75 seconds of sequential processing. With parallelization (we run 8 images concurrently), the total build time is about 10-15 seconds.

15 seconds for 480 unique, branded social cards. That's the power of build-time generation with the right tools.


IV – Designing for 1200x630

OG images have a standard size: 1200 pixels wide by 630 pixels tall. This aspect ratio (roughly 1.91:1) is dictated by Facebook's original spec and adopted by every major platform.

Designing for this format is different from designing for the web.

There's no scrolling. There's no interaction. There's no responsive breakpoint. You have exactly 1200x630 pixels, and you need to communicate the page's value proposition in a glance.

Here's what we learned about effective OG image design:

Text must be large. OG images often appear as thumbnails in feeds. Text smaller than 40px becomes unreadable at thumbnail size. Our minimum text size is 48px. Headlines are 72px.

Use no more than three visual elements. A logo, a headline, and one supporting element (a subtitle, an icon, or a decorative line). More than three elements creates visual noise that collapses at thumbnail scale.

High contrast is non-negotiable. The Void Slate dark theme of mentoring.oakoliver.com uses light text on a dark background. This is ideal for OG images because dark backgrounds command attention in bright social media feeds where most content is white.

Leave generous padding. Different platforms crop OG images slightly differently. Twitter crops to a 2:1 ratio. LinkedIn crops to 1.91:1. Slack maintains the full image but adds rounded corners. A 60px safe zone around all edges ensures content isn't clipped regardless of platform.


V – The Component Architecture

Each OG image is a function that takes page data as input and returns a JSX tree.

But here's the nuance: Satori's JSX is not React JSX. It looks like React. It uses the same syntax. But it supports a limited subset of CSS, and the rendering is different.

What Satori supports: Flexbox layout (including flex-direction, align-items, justify-content, gap), absolute positioning, borders, border-radius, background colors, background images (via base64 data URIs), text styling (font-size, font-weight, color, line-height, letter-spacing), and a few more.

What Satori doesn't support: CSS Grid, transforms, animations, pseudo-elements, media queries, overflow: hidden (partial), gradients (partial), box-shadow (partial), and most advanced CSS features.

This constraint is actually liberating. You can't over-design an OG image when you only have flexbox and basic styling. The constraint pushes you toward clean, simple compositions.

We structured our OG image components as a set of composable pieces:

The base layout — a full-size container with the dark background, border, and safe-zone padding. Every OG image starts with this.

The logo block — the Oak Oliver wordmark positioned in the top-left corner. Consistent across all images.

The content block — the variable part. This is where the page-specific content goes: title, description, locale flag, mentor photo, or whatever the page needs.

The footer block — the URL of the page, positioned at the bottom. This reinforces where the link goes.

These pieces compose differently for different page types. A homepage OG image uses a large centered title. A mentor profile uses a side-by-side layout with a photo and bio text. A locale-specific page includes the locale name and flag emoji.


VI – The Font Problem

Fonts are the hardest part of OG image generation. Harder than layout. Harder than colors. Harder than the SVG-to-PNG conversion.

Satori needs font files at render time. Not system fonts. Not CSS font-face declarations. Actual binary font files loaded into memory. And it needs the right font file for the text being rendered.

For English, this is straightforward — load a single font family with regular and bold weights. Two files.

For 40 locales? It's a nightmare.

Japanese text requires a CJK font. Arabic text requires an Arabic font with proper shaping. Thai text requires a Thai font. Each font file is 2-20MB. Loading all of them would consume hundreds of megabytes of memory.

Our solution: locale-aware font loading.

When generating OG images for a specific locale, we only load the fonts needed for that locale. English pages load the Latin font. Japanese pages load the CJK font. Arabic pages load the Arabic font. The font loading is part of the generation pipeline, not a global initialization.

We pre-identified which font files cover which Unicode ranges, and we built a mapping from locale to font files. When the pipeline processes a locale, it loads only the relevant fonts, generates all images for that locale, then unloads the fonts and moves to the next locale.

This keeps memory usage under 200MB at any point during the build, compared to 800MB+ if we loaded all fonts upfront.

The font file storage uses a deliberate convention: fonts are named by their Unicode coverage, not their family name. This makes the locale-to-font mapping explicit and maintainable.


VII – The Void Slate Design System

The mentoring platform uses a design system we call Void Slate. It's a dark-first aesthetic — deep blacks, muted grays, and crisp white text with occasional amber accents.

OG images must match the site's visual identity. If a user clicks a rich social card and lands on a page that looks completely different, the cognitive dissonance reduces trust.

Our OG images use the exact same color tokens as the web application:

  • Background: the deep black from the Void Slate palette
  • Text: the off-white used for body text
  • Accents: the amber used for interactive elements
  • Borders: the subtle gray used for card borders

The typography matches too — same font family, similar sizing ratios, similar letter spacing.

The result: the OG image looks like a screenshot of the site. When someone clicks through, the page they land on feels like a continuation of what they saw in the card. No surprise. No disconnect.

This consistency is impossible with generic OG image templates. Those templates use their own colors, their own fonts, their own layout. They're a different visual language from the site they represent. Programmatic generation with your own design tokens eliminates this problem entirely.


VIII – Handling Dynamic Data: Mentor Profiles

The most complex OG images are mentor profile cards.

Each mentor has a name, a photo, a title, specialization tags, an hourly rate, a star rating, and a short bio. All of this needs to fit in a 1200x630 image that remains legible at thumbnail scale.

The layout challenge is text length variability.

A mentor named "Ana Silva" takes much less horizontal space than "Dr. Alexandria Konstantinidis-Papadopoulos." A specialization like "React" is compact; "Enterprise Architecture & Cloud Migration" is not.

We handle this with a cascading layout strategy:

Level 1: Try the full layout with all elements. If everything fits within the safe zone, render it.

Level 2: If the name is too long, truncate it with an ellipsis. Recalculate. If it fits, render.

Level 3: If specialization tags overflow, show only the first two tags plus a "+N more" indicator. Recalculate.

Level 4: If the bio text is too long, truncate to two lines with ellipsis.

This cascading approach means every mentor profile card looks intentional, not broken. Short names get spacious layouts. Long names get compact ones. The system adapts without human intervention.

The photo handling deserves mention. Mentor photos are circular, cropped from the center, and composited onto the dark background. Satori doesn't support image masking directly, so we pre-process photos into circular PNGs during the SSG pipeline and embed them as base64 data URIs. This adds about 30KB per image to the Satori input, but the rendering time impact is negligible.


IX – Locale-Specific Adaptations

OG images for different locales aren't just translated text. They're adapted layouts.

Right-to-left locales (Arabic, Hebrew) flip the entire layout. The logo moves to the top-right. Text aligns right. The mentor photo moves to the right side. This isn't CSS direction: rtl — Satori doesn't support it. It's a separate component tree with mirrored flexbox alignment.

CJK locales (Japanese, Chinese, Korean) use different typographic rules. CJK text doesn't word-wrap at spaces — it wraps at any character boundary. Line heights need to be taller to accommodate the denser character forms. We adjust padding and font-size slightly for CJK locales to ensure legibility.

Long-text locales (German, Finnish, Portuguese) produce text that's 20-40% longer than English. The cascading layout strategy handles this, but we also increase the truncation aggressiveness for these locales. A German headline that would be two lines in English might be three lines in German, so we truncate to two lines earlier.

The emoji flag. Each locale's OG image includes a small flag emoji next to the locale name. Satori supports emoji rendering through its font system — you need a font that includes emoji glyphs. We use the Noto Color Emoji font, loaded only for the emoji rendering pass.

All of these adaptations are data-driven. The locale configuration file specifies direction (ltr/rtl), font set, text length factor, and any layout overrides. The OG image components read this configuration and adapt accordingly.


Want to make every page on your site shareable?

The mentoring platform at mentoring.oakoliver.com generates 480+ unique OG images at build time, ensuring every page — in every language — looks stunning when shared on social media. If you're building a multi-locale platform and want to discuss SEO, SSG pipelines, or social media optimization, book a session at mentoring.oakoliver.com.

Explore the full Oak Oliver ecosystem at oakoliver.com.


X – The Build Pipeline Integration

OG image generation is a step in our SSG build pipeline, not a separate process.

The SSG pipeline runs in this order:

  1. Load all data from the database (mentors, locales, routes)
  2. Generate HTML pages for all 480 routes
  3. Generate OG images for all 480 routes
  4. Generate sitemaps and robots.txt
  5. Copy static assets
  6. Output everything to the build directory

Steps 2 and 3 share the same data. The mentor data loaded in step 1 feeds both the HTML generation and the OG image generation. No duplicate database queries. No data inconsistency between the page and its OG image.

This matters more than it sounds. If the OG image generation were a separate process, it might run with slightly different data — a mentor who updated their bio between the HTML build and the OG build would have a mismatched card. By sharing the data load, the page and its OG image are always in sync.

The output structure mirrors the URL structure. The OG image for /en/mentors/john-doe lives at /og/en/mentors/john-doe.png. The HTML page references this path in its meta tags. Simple, predictable, debuggable.


XI – Performance Optimization: Parallelism and Caching

Generating 480 images sequentially takes about 75 seconds. That's acceptable for a CI/CD pipeline but painful for local development when you're iterating on the OG image design.

We optimized with two strategies: parallelism and caching.

Parallelism. The 480 images are independent — each one depends on its own data and nothing else. We process them in batches of 8, using Promise.all with a concurrency limiter. On an 8-core machine, this brings the total time down to about 12 seconds.

Why 8 and not higher? Memory. Each Satori render holds the SVG and font data in memory. With CJK fonts at 10-20MB each, 16 concurrent renders would spike memory to over 1GB. Eight concurrent renders keep memory under 400MB — comfortable for both local machines and our CI server.

Caching. Most builds don't change most OG images. If a mentor's data hasn't changed, their OG image is identical to the last build. We compute a hash of each image's input data and compare it to the hash from the previous build. If it matches, we skip generation and reuse the cached PNG.

In practice, a typical build (after the initial full generation) only regenerates 5-20 images — the ones affected by data changes. The build time drops from 12 seconds to under 2 seconds.

The cache invalidation is conservative. If we're unsure whether an image needs regeneration, we regenerate it. A false positive (unnecessary regeneration) costs 100ms. A false negative (serving a stale image) costs user trust. We always choose the safe option.


XII – The Meta Tags: Connecting Image to Page

Generating the image is half the battle. The other half is telling social media crawlers where to find it.

Each HTML page includes these meta tags in its head:

  • An og:image tag with the absolute URL of the OG image
  • An og:image:width tag set to 1200
  • An og:image:height tag set to 630
  • An og:image:type tag set to image/png
  • A twitter:card tag set to summary_large_image
  • A twitter:image tag (same URL as og:image)

The absolute URL is critical. Crawlers resolve og:image URLs relative to the page, and not all of them do it correctly. Using a fully qualified URL (starting with https://) eliminates ambiguity.

The width and height tags are technically optional but practically essential. Without them, some crawlers fetch the image just to determine its dimensions before deciding to display it. With the dimensions pre-declared, the crawler knows the image will fit the card format and renders it without an extra request.

The twitter:card value matters. "summary" shows a small square thumbnail. "summary_large_image" shows the full-width image card. Since our images are designed for the 1200x630 format, the large image card is the correct choice.


XIII – Testing: How Do You Verify 480 Images?

You can't manually inspect 480 images on every build. You need automated validation.

Layer 1: Generation success. The build pipeline tracks how many images were generated and how many failed. If any image fails to generate, the build fails. No partial deployments with missing OG images.

Layer 2: Dimension validation. After generation, we verify that every PNG is exactly 1200x630. A dimension mismatch usually indicates a Satori configuration error or a font rendering issue that collapsed the layout.

Layer 3: File size sanity. OG images should be 50-300KB. An image under 10KB probably has rendering issues (blank or nearly blank). An image over 500KB probably has an embedded photo that wasn't properly compressed. We flag outliers.

Layer 4: Visual regression (selective). For the 12 English route images (our "reference" locale), we compare against golden files using pixel-diff. If the diff exceeds a threshold, the build warns but doesn't fail — because the diff might be intentional (design change). This catches unintended visual regressions without blocking intentional updates.

Layer 5: Crawler simulation. A post-deploy check uses a headless fetch with a social-media-crawler user agent to verify that each page's og:image URL returns a valid PNG. This catches deployment issues where the images exist but aren't properly served.


XIV – The Debugging Story: When Images Go Wrong

My favorite debugging story from this system involves a Japanese mentor profile where the OG image rendered perfectly everywhere except LinkedIn.

LinkedIn's crawler was showing a broken image icon. Twitter was fine. Facebook was fine. Slack was fine. Just LinkedIn.

After hours of investigation, we discovered that LinkedIn's crawler has a stricter timeout than other crawlers — about 3 seconds. And our CDN was serving the Japanese OG image from a cold cache, which required fetching from the origin server in Finland. The round trip was just over 3 seconds.

The fix: pre-warm the CDN cache after deployment. A post-deploy script fetches every OG image URL once, ensuring the CDN has a warm copy before any crawler hits it.

This isn't a Satori problem or a resvg problem or a font problem. It's a distribution problem. Your images can be perfectly generated and completely broken if the delivery is too slow.

The lesson: OG image quality is end-to-end. Generation, storage, caching, CDN, and crawler timeout — every link in the chain matters.


XV – What Satori Can't Do (And What We Do Instead)

Satori is remarkable for what it is, but it has limitations that shaped our design.

No gradients. Satori's CSS gradient support is incomplete. We wanted a subtle gradient overlay on mentor photos. Instead, we pre-process photos with a programmatic vignette effect before embedding them.

No complex SVG. Satori generates SVG, but it can't embed arbitrary SVG graphics. Our logo is an SVG, but we convert it to a PNG and embed it as a base64 data URI instead of trying to nest SVGs.

No CSS Grid. All layout is flexbox. For OG images this is fine — flexbox handles 1200x630 card layouts perfectly. But if you're trying to render a complex dashboard-style image with a grid layout, you'll struggle.

No opacity on images. You can set opacity on text and background colors but not on embedded images. We work around this by pre-processing images with the desired opacity before embedding.

No text shadow. We use a workaround — rendering the same text twice, once in the shadow color offset by a pixel, once in the foreground color. It works but it's tedious.

Every limitation has a workaround. Some are elegant. Some are hacky. But the trade-off — generating 480 branded images in 12 seconds without a headless browser — is worth every workaround.


XVI – The Alternative We Didn't Choose: Puppeteer

The "obvious" approach to dynamic OG images is spinning up a headless browser, navigating to a page (or a special OG-image route), taking a screenshot, and saving it.

Puppeteer, Playwright, or any headless Chrome solution can do this. And the result would support all CSS — gradients, grid, animations frozen mid-frame, anything a browser can render.

We rejected this approach for three reasons.

Speed. Launching a headless browser, navigating to a page, waiting for render, and taking a screenshot takes 2-5 seconds per image. For 480 images, that's 16-40 minutes even with parallelism. Our Satori pipeline does it in 12 seconds.

Resource usage. A headless Chrome instance consumes 200-400MB of RAM. Running 8 in parallel would require 1.6-3.2GB just for OG image generation. Our Satori pipeline uses under 400MB total.

Reliability. Headless browsers crash. They leak memory. They hang on complex pages. They're non-deterministic — the same page can render slightly differently across runs due to font hinting, anti-aliasing, and timing. Satori is deterministic. Same input, same output, every time.

The Puppeteer approach trades rendering fidelity for speed, resources, and reliability. For projects where the OG image design requires full CSS support, that trade-off might be worth it. For our use case — branded cards with controlled designs — Satori's subset is more than sufficient.


XVII – The ROI of Rich Social Cards

Let me talk about the business impact, because generating OG images isn't just a technical exercise.

Before we implemented dynamic OG images, links to the mentoring platform shared on social media rendered as plain URLs with the default fallback image (a generic Oak Oliver logo). Click-through rates from social shares were around 1.2%.

After implementing unique OG images for every page, click-through rates jumped to 3.8%.

A 3x improvement from a PNG file.

Mentor profile links are the most dramatic. A social card showing the mentor's photo, name, specialization, and rating tells the viewer exactly what they'll find when they click. There's no mystery. No risk of wasting a click. The card is the pitch.

The locale-specific cards help too. When a French mentor shares their profile link in a French-language community, the card renders in French. The viewer immediately recognizes it as relevant content in their language. Without locale-specific OG images, the English-default card would be less compelling.

Social media is a visual medium. Text links are invisible. Image cards are loud. If you're not generating unique OG images for your important pages, you're leaving clicks on the table.


XVIII – The Question Nobody Asks

When I share this system with other developers, they always ask about the implementation. How does Satori work? How do you handle fonts? How fast is resvg?

Nobody asks the question that actually matters:

How do you decide what goes on the image?

The technology is the easy part. The hard part is looking at 1200 pixels by 630 pixels and deciding which words earn that space. Which visual hierarchy communicates the page's value. Which information makes someone click.

For a mentor profile, we tested five different layouts. The one that won — photo on the left, name and specialization on the right, rating at the bottom — wasn't the prettiest. It was the clearest. At thumbnail size, you could identify the person, their expertise, and their quality in under a second.

OG image design is a copywriting problem disguised as a rendering problem.

The best OG images aren't the ones with the fanciest effects. They're the ones that answer the viewer's implicit question — "why should I click this?" — in the space of a glance.

So here's my question for you:

If someone shared a link to the most important page of your product right now, what would they see — and does it do justice to what's behind the click?

If the answer is a generic logo or a plain URL, you're making the first impression on behalf of your product and choosing to make it forgettable.

– Antonio

"Simplicity is the ultimate sophistication."