Dynamic OG Images: A Journey Through Development Hell

There's something oddly satisfying about making your website generate its own social media preview images on the fly. What started as a simple desire to have dynamic Open Graph images turned into a proper development ordeal involving fonts that wouldn't load, endpoints that mysteriously worked in dev but not production, and enough mild swearing to power a small village.

The Vision

I wanted something straightforward: when someone shares a link to my blog or website, it should show a beautiful, contextual preview image rather than some generic fallback. Each page should get its own custom-generated OG image with the right title, subtitle, maybe some metadata, and my branding. Simple enough, right?

Famous last words.

The Technical Stack

I went with Satori for converting React-like components to SVG, and @resvg/resvg-js to turn those SVGs into PNG buffers. The concept is elegant: define your layout in a React-like syntax, let Satori render it to SVG, then convert to PNG for browsers that prefer actual images over bleeding-edge formats.

The fonts were another matter entirely. I'm using Recursive throughout my site—a lovely variable font family that handles everything from code blocks to body text. For the OG images, I needed the static variants: regular, bold, and italic weights.

The Font Loading Nightmare

Here's where things got interesting. In development, everything worked brilliantly. The fonts loaded from the filesystem, images generated perfectly, and I felt like a proper developer for about five minutes.

Then I deployed to production.

Suddenly, the fonts weren't loading. The filesystem approach that worked locally was useless in a serverless environment. I needed to fetch fonts from the served static files, but this introduced a new problem: in development, there's no base URL, while in production, I needed the full domain.

The solution involved detecting the environment and switching strategies:

async function loadSingleFont(fileName: string, baseUrl?: string): Promise<Buffer> {
try {
if (dev) {
// Development: load from filesystem
const fs = await import('fs/promises');
const path = await import('path');
const fontPath = path.resolve(`static${FONT_BASE_URL}/${fileName}`);
return await fs.readFile(fontPath);
} else {
// Production: fetch from served static files
const fontUrl = `${baseUrl || ''}${FONT_BASE_URL}/${fileName}`;
const response = await fetch(fontUrl);
if (!response.ok) {
throw new Error(`Failed to fetch font: ${response.status} ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}
} catch (error) {
console.error(`Failed to load font ${fileName}:`, error);
throw error;
}
}

The Endpoint Extension Fiasco

But wait, there's more. For some fucking reason (and I genuinely cannot explain this), the API endpoints worked perfectly in development without file extensions, but in production they simply refused to serve anything useful.

In dev: /api/og/blog worked fine.

In production: /api/og/blog returned nothing.

Also in production: /api/og/blog.png worked perfectly.

This led to commit #3, where I systematically went through every single OG route and appended .png to make them work in production. I renamed files, updated all the references in components, and fixed the routing. It felt like digital busywork, but it was the only way to make the bloody things work.

The commit message was appropriately terse: "fix(og): append .png to the og routes to make sure they work."

Dynamic Sizing and Layout Logic

The actual image generation logic turned out to be quite satisfying once the infrastructure worked. I implemented dynamic font sizing based on content length:

function calculateTitleFontSize(title: string, maxWidth: number = 1000): number {
const baseSize = 64;
const charThreshold = 45;
const minSize = 36;

if (title.length <= charThreshold) {
return baseSize;
}

const scaleFactor = Math.max(0.5, 1 - ((title.length - charThreshold) * 0.012));
return Math.max(minSize, Math.floor(baseSize * scaleFactor));
}

Long titles get smaller fonts. Subtitles scale proportionally. If the content threatens to overflow the image bounds, margins get compressed. It's responsive design for social media previews.

The layout system handles different content types: blog posts get titles, subtitles, and reading time metadata. The main page gets a decorative line. Pages with author information get a proper byline with avatar. Everything centers beautifully and maintains the site's dark green aesthetic.

Cohesive Design Philosophy

One thing I was determined to get right was visual consistency. These OG images needed to feel like natural extensions of the website, not awkward appendages. I used the same colour palette (#121c17 for the background, #d8e8d8 for primary text, #8fd0a0 for accents), the same typography choices, and maintained the same spacing philosophy as the rest of the site.

The result is OG images that genuinely look like they belong, whether you're sharing a blog post, the main page, or individual status updates.

The Final Result

After all the frustration, mild swearing, and debugging sessions that stretched longer than they had any right to, the system works beautifully. Each page on my site now generates its own contextual OG image with proper typography, appropriate sizing, and cohesive branding.

When someone shares a link, they get a preview that actually represents the content rather than some generic placeholder. It's a small detail, but it makes the whole site feel more polished and intentional.

Lessons Learned

Environment differences are real and they will bite you. What works in development might fail spectacularly in production for reasons that make no logical sense.

Font loading in serverless environments requires thought. You can't just read from the filesystem in production.

File extensions matter in production routing. Even when they don't matter in development.

Dynamic content sizing is worth the complexity. Being able to handle various content lengths gracefully makes the system much more robust.

Consistency matters more than perfection. Getting the branding and typography right across all generated images creates a much better user experience than having technically perfect but visually disconnected previews.

The system now handles blog posts, status updates, and static pages with aplomb. It was frustrating to build, but having dynamic OG images that actually represent the content has been worth every moment of development hell.

Sometimes the best features are the ones that work so seamlessly that users never have to think about them—they just see better previews when sharing your content, and the magic happens entirely behind the scenes.