This page was opened from file://. Chromium blocks the local CSS and JS for this site in that mode. Preview it over HTTP with pnpm dev or pnpm preview.

Skip to main content
dout.dev Frontend notes, design systems, and the sharp edges of shipping.

Article

/ Archive

OG Images at Build Time

The problem and the usual solutions

OG Images at Build Time

Article content

The problem and the usual solutions

Every post needs a social preview image - the card that appears when someone shares the URL on Slack, Twitter, or LinkedIn. The options usually break down like this.

  • Hand-design each one. A Figma file, a designer, exported PNGs. Works. Does not scale.
  • Runtime service (Vercel OG, Cloudinary). A URL like og.dev/render?title=... produces an image on demand. Works. Adds a third-party dependency, latency, and a potential point of failure.
  • Build-time renderer. Generate the PNG when the site builds, commit it to the static output. Works. Is under your control.

For dout.dev I went build-time, for one reason: I wanted the image to be a byproduct of the build, cacheable as a static file, with zero runtime dependency. The implementation is about 150 lines of Node.

The SVG template

The trick is that OG images are rasterized SVGs. SVG is a markup language I can generate with the same template engine as the rest of the site. Sharp then converts the SVG to PNG at 1200×630, which is the OG card spec.

<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
  <rect width="1200" height="630" fill="#0b0b0f" />

  <text x="80" y="180" font-family="Inter, sans-serif" font-size="24" fill="#ff6b3d"
        font-weight="600" letter-spacing="0.08em">DOUT.DEV</text>

  <text x="80" y="320" font-family="Inter, sans-serif" font-size="64" fill="#e7e7ef"
        font-weight="700" style="line-height: 1.1">
    <tspan x="80" dy="0">OG Images at Build Time</tspan>
    <tspan x="80" dy="80">SVG + Sharp in 150 Lines</tspan>
  </text>

  <text x="80" y="540" font-family="Inter, sans-serif" font-size="24" fill="#a0a0b4">
    by Emiliano "pixu1980" Pisu · 11 Aug 2026
  </text>
</svg>

That template has variables where the title, date, and author go. The generator produces one per post.

The line-break problem

The hardest single problem in OG image generation is fitting a variable-length title inside a fixed-width box without it running off the edge.

The brute-force solution - "measure the text, wrap manually" - requires a font metrics library. The simpler solution - greedy wrapping with a conservative line-length budget - is what I use.

function wrapTitle(title, maxChars = 26) {
  const words = title.split(/\s+/);
  const lines = [];
  let current = '';
  for (const word of words) {
    if ((current + ' ' + word).trim().length > maxChars) {
      lines.push(current);
      current = word;
    } else {
      current = (current + ' ' + word).trim();
    }
  }
  if (current) lines.push(current);
  return lines.slice(0, 3);
}

26 characters is an empirical value for Inter at 64px in a 1040px box with 80px margins. I tuned it once, compared against the longest real titles, and moved on. The generator truncates to three lines because four looks cramped.

The rasterization

Sharp handles the SVG-to-PNG conversion. It is a one-liner with reasonable defaults.

import sharp from 'sharp';

async function renderOg(svg, outputPath) {
  await sharp(Buffer.from(svg)).resize(1200, 630).png({ compressionLevel: 9 }).toFile(outputPath);
}

compressionLevel: 9 squeezes the PNG a bit at the cost of build time. On a small blog the total is under a second per image, so it is worth it.

The full generator, in shape

import { readFileSync } from 'node:fs';
import sharp from 'sharp';

const template = readFileSync('src/og-template.svg', 'utf8');

export async function generateOgImage(post, outputPath) {
  const lines = wrapTitle(post.title);
  const titleSvg = lines.map((line, i) => `<tspan x="80" dy="${i === 0 ? 0 : 80}">${escapeXml(line)}</tspan>`).join('');

  const svg = template
    .replace('<title-placeholder>', titleSvg)
    .replace('<date-placeholder>', formatDate(post.date))
    .replace('<author-placeholder>', 'Emiliano &quot;pixu1980&quot; Pisu');

  await sharp(Buffer.from(svg)).png({ compressionLevel: 9 }).toFile(outputPath);
}

150 lines with the helpers included. No fonts-as-files wizardry. No headless browser. No Puppeteer.

Fonts: the caveat you have to handle

SVG font-family="Inter" only works if the system rendering the SVG has Inter installed, which is not the case on GitHub Actions runners.

Two paths:

  1. Embed the font as a base64 data URL inside the SVG. Works. Balloons the file size.
  2. Preload the font at the Sharp layer. Sharp uses the system fontconfig; you install the font on the runner and reference it by name.

On dout.dev I went with option 2: a GitHub Actions step installs Inter before the build runs. That keeps the SVG clean.

- name: Install Inter font
  run: |
    wget -q https://fonts.google.com/download?family=Inter -O inter.zip
    unzip -q inter.zip -d /usr/share/fonts/inter
    fc-cache -f

The URL for Google Fonts bulk download is stable enough; if it breaks, I self-host the .ttf in the repo.

What the generator outputs

One PNG per post, under src/assets/og/posts/<slug>.png. The CMS also generates month OG images under src/assets/og/months/ and writes a manifest at src/assets/og/manifest.json so other build steps can reference them.

The post template references the image in the <head>:

<meta property="og:image" content="https://dout.dev/assets/og/posts/2026-08-11-og-images-at-build-time.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />

og:image:width and og:image:height are not required, but many crawlers check them and Mastodon's preview service will happily show a tiny image if dimensions are missing.

The takeaway

You do not need a runtime OG service, a headless browser, or a third-party API. A templated SVG, a wrapping function, and Sharp cover the whole problem in 150 lines. It runs at build time, commits to dist/, and costs nothing after the first build.

References

Discussion

Comments live in GitHub Discussions

Each thread is keyed to the source markdown entry for this post.