Skip to content
9 min read engineering, astro, tutorial

Generating Open Graph Images with Satori and Sharp

How to mint a custom social-share card for every page at build time — JSX to SVG to PNG, no headless browser. The exact setup running on this site.

When you paste a link into Slack, LinkedIn, Discord, or iMessage, the preview card is the og:image for that page. Most sites ship one generic image — the company logo on a flat background — for every URL they have. It’s forgettable. You scroll past it.

A card that carries the page’s own headline is a different thing entirely. It looks made. It looks worth a click. And you can generate one for every page automatically, at build time, with no headless browser and zero runtime cost.

This is how I do it on this site. The tools are Satori and Sharp. It’s the longer version of one section from my Astro portfolio walkthrough — here I’m zooming all the way in.

Why not a headless browser?#

The obvious approach is: write an HTML template, screenshot it with Puppeteer or Playwright. It works. It’s also heavy — you’re bundling Chromium (hundreds of megabytes), spawning a browser process, and waiting for a full page render just to crop a 1200×630 rectangle. On a CI runner it’s slow; on a serverless function it’s a cold-start nightmare.

Satori takes a different path. It doesn’t render a page — it takes a tree of element objects (the same shape React produces) and lays them out itself, then emits SVG. No browser, no DOM, no network. It’s the engine behind Vercel’s OG image library, pulled out so you can run it anywhere Node runs — including a build script.

The tradeoff: Satori implements a subset of CSS. Flexbox, yes. CSS Grid, no. Most of position, padding, border, background, text styling — yes. Anything exotic — no. For a share card, the subset is more than enough. You’re laying out a headline, a logo, and a byline in a box. That’s three flex children.

What Satori actually does#

One function call:

import satori from "satori";

const svg = await satori(element, {
  width: 1200,
  height: 630,
  fonts: [/* ... */],
});

element isn’t JSX — it’s the object JSX compiles to. In a build script there’s no JSX transform, so you write the objects by hand:

const element = {
  type: "div",
  props: {
    style: { display: "flex", color: "white" },
    children: "Hello",
  },
};

Verbose, but honest about what’s happening. (If you’d rather write JSX, you can — point your build script at a .jsx file and a transform — but for one card builder the plain objects are fine, and they make the “this is just data” nature obvious.)

Setup#

npm i satori sharp

Two dependencies. Satori does the layout → SVG; Sharp does SVG → PNG (because most platforms want a raster image for og:image, and some — looking at you, older Twitter cards — won’t render SVG at all).

The card is a function#

Treat the card as a pure function of its content. Title in, element tree out:

// scripts/build-images.mjs
function ogCard({ title, eyebrow = "Journal" }) {
  return {
    type: "div",
    props: {
      style: {
        width: "1200px",
        height: "630px",
        display: "flex",
        flexDirection: "column",
        justifyContent: "space-between",
        padding: "70px 80px",
        backgroundColor: "#0a0a0a",
        // A warm radial in one corner — same brand language as the site
        backgroundImage:
          "radial-gradient(800px 500px at 100% 0%, rgba(255,90,31,0.32), transparent 60%)",
        color: "#f3efe8",
        fontFamily: "Instrument Sans",
      },
      children: [
        // Eyebrow
        {
          type: "div",
          props: {
            style: {
              fontSize: "14px",
              letterSpacing: "0.22em",
              textTransform: "uppercase",
              color: "#a0a0a0",
            },
            children: eyebrow,
          },
        },
        // Headline — serif, large, shrinks a notch for long titles
        {
          type: "div",
          props: {
            style: {
              fontFamily: "Noto Serif",
              fontSize: title.length > 50 ? "62px" : "78px",
              lineHeight: 1.05,
              letterSpacing: "-0.025em",
              maxWidth: "1040px",
            },
            children: title,
          },
        },
        // Byline
        {
          type: "div",
          props: {
            style: {
              display: "flex",
              justifyContent: "space-between",
              fontSize: "16px",
              letterSpacing: "0.18em",
              textTransform: "uppercase",
              color: "#848484",
              borderTop: "1px solid #1f1f1f",
              paddingTop: "24px",
            },
            children: [
              { type: "span", props: { children: "Your Name" } },
              { type: "span", props: { children: "yoursite.com" } },
            ],
          },
        },
      ],
    },
  };
}

A couple of things worth pointing out in there. justify-content: space-between on the outer column does the vertical layout for free — eyebrow pinned top, byline pinned bottom, headline floating in the middle — no manual spacing. And the title.length > 50 ? "62px" : "78px" line is a one-line autofit: long headlines step down a size so they don’t overflow the box. You can get fancier (binary-search the font size until it fits), but a single breakpoint covers most real titles.

Fonts are not optional#

Satori has no system fonts. If you don’t pass font data, text doesn’t render — it just isn’t there. You have to hand it the raw font buffer for every family/weight/style you reference.

Don’t fetch fonts over the network at build time (flaky, slow, and now your build depends on Google’s CDN). If you’re already self-hosting fonts via @fontsource — which you should be, for the page itself — the .woff files are sitting in node_modules:

import { readFile } from "node:fs/promises";

const [serif, serifItalic, sans] = await Promise.all([
  readFile("node_modules/@fontsource/noto-serif/files/noto-serif-latin-400-normal.woff"),
  readFile("node_modules/@fontsource/noto-serif/files/noto-serif-latin-400-italic.woff"),
  readFile("node_modules/@fontsource/instrument-sans/files/instrument-sans-latin-500-normal.woff"),
]);

const fonts = [
  { name: "Noto Serif", data: serif, weight: 400, style: "normal" },
  { name: "Noto Serif", data: serifItalic, weight: 400, style: "italic" },
  { name: "Instrument Sans", data: sans, weight: 500, style: "normal" },
];

Rule of thumb: every (fontFamily, fontWeight, fontStyle) combination that appears anywhere in your card’s styles must have a matching entry in fonts. Reference fontWeight: 700 without loading a 700 weight and Satori either falls back oddly or renders nothing. Load only the weights you actually use — each one is a chunk of your build’s memory.

Embedding an image — the one that bites you#

Want your logo on the card? Satori embeds <img> elements, but only from PNG, JPEG, or SVG sources, and in a build script the cleanest way to pass one is a base64 data URI. If your logo is a WebP (very likely, in 2026), Satori won’t take it. Convert it first — with Sharp, which you already have:

import sharp from "sharp";

const logoPng = await sharp("public/branding/logo.webp").png().toBuffer();
const LOGO_DATA_URL = `data:image/png;base64,${logoPng.toString("base64")}`;

Then use it like any image element:

{ type: "img", props: { src: LOGO_DATA_URL, width: 56, height: 40 } }

This cost me a confused ten minutes the first time — the card rendered fine except the logo was just gone, no error. The fix is the conversion above. Worth knowing before it happens to you.

SVG → PNG with Sharp#

The other half of the pipeline, and it’s a one-liner:

import { writeFile } from "node:fs/promises";

const png = await sharp(Buffer.from(svg)).png({ compressionLevel: 9 }).toBuffer();
await writeFile("public/og/some-page.png", png);

compressionLevel: 9 is the slowest/smallest setting — fine here, because this runs once per build, not per request, and you want the deployed PNGs small.

One card per post, automatically#

The payoff is hooking this to your content. Read the content directory, parse each file’s title out of the frontmatter, render a card per entry:

// scripts/build-images.mjs (continued)
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";

const journalDir = "src/content/journal";
const outDir = "public/og";
await mkdir(outDir, { recursive: true });

// Tiny frontmatter reader — we only need `title` and a draft check.
function frontmatter(raw) {
  const m = raw.match(/^---\n([\s\S]*?)\n---/);
  if (!m) return {};
  const data = {};
  for (const line of m[1].split("\n")) {
    const [k, ...rest] = line.split(":");
    if (!k || !rest.length) continue;
    let v = rest.join(":").trim().replace(/^["'](.+)["']$/, "$1");
    if (v === "true") v = true;
    else if (v === "false") v = false;
    data[k.trim()] = v;
  }
  return data;
}

const files = (await readdir(journalDir)).filter((f) => /\.(md|mdx)$/.test(f));

for (const file of files) {
  const raw = await readFile(join(journalDir, file), "utf8");
  const { title, draft } = frontmatter(raw);
  if (draft || !title) continue;

  const slug = file.replace(/\.(md|mdx)$/, "");
  const svg = await satori(ogCard({ title }), { width: 1200, height: 630, fonts });
  const png = await sharp(Buffer.from(svg)).png({ compressionLevel: 9 }).toBuffer();
  await writeFile(join(outDir, `${slug}.png`), png);
  console.log(`  [og] wrote ${slug}.png`);
}

Yes, I’m parsing frontmatter with a regex instead of pulling in gray-matter. For “give me the title line,” a dependency is overkill — but if you’re already doing heavier frontmatter work elsewhere, reuse that. The point is: one file maps to one card, derived from the file itself. Add a post, the next build mints its card. Nothing to remember.

Same trick works for your static pages — keep a small array of { slug, title, eyebrow } and loop it through the same ogCard() function, so /about, /contact, etc. each get a card too. (One nuance: a page’s share-card headline doesn’t have to be its on-page <h1> — the H1 serves hierarchy and SEO, the card headline serves click-through. Optimize each for its job.)

Wire it into the build#

Make it run before every build, automatically, via the prebuild lifecycle script:

// package.json
{
  "scripts": {
    "prebuild": "node scripts/build-images.mjs",
    "build": "astro build"
  }
}

npm run build runs prebuild first, every time. (pnpm build does the same — pnpm honors the pre/post lifecycle hooks.) Cards regenerate on every deploy; rename a post and its card follows. There’s no separate “regenerate the OG images” step that someone forgets.

Then point your <head> at the card. In an Astro layout:

---
const ogImage = `/og/${slug}.png`;
const ogURL = new URL(ogImage, Astro.site);
---
<meta property="og:image" content={ogURL} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={title} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={ogURL} />
<meta name="twitter:image:alt" content={title} />

Absolute URL matters — og:image must be fully qualified, not /og/foo.png. And twitter:image:alt doesn’t fall back to og:image:alt; emit both.

And then it’s recursive in a satisfying way: the share card for this post — the one that shows up when this URL gets pasted somewhere — was minted by exactly the script above, from this post’s own title. Paste the link and see.

Gotchas worth knowing#

A short field guide to the things that’ll trip you up, collected so they don’t have to:

  • Every element with more than one child needs display: flex. Satori is strict here — a plain <div> wrapping two children without display: flex throws. When in doubt, add display: flex.
  • Text doesn’t wrap without a width constraint. Give the headline element a maxWidth (or width) or a long title runs straight off the canvas.
  • gap works, but only inside a flex container — same as real flexbox. Outside one, it’s ignored.
  • No display: grid. Compose with nested flex rows/columns instead. It’s enough.
  • Fonts must cover your characters. A latin-only subset won’t render an em dash’s neighbours… actually it will — but accented names, non-latin text, or fancy punctuation may need a fuller subset. Test with your real content.
  • backgroundImage supports gradients, including radial — handy for brand texture without shipping an image.
  • Debug by writing the SVG to disk. await writeFile("debug.svg", svg) and open it in a browser — you’ll see exactly what Satori produced before Sharp rasterizes it.

What you end up with#

A prebuild step that, on every deploy, turns each page and post into a branded share card carrying its own headline — laid out by Satori, rasterized by Sharp, written to public/og/. No Chromium in your dependencies, no render service to pay for, no runtime cost at all. Just a function from title to PNG, run at build time.

The card is the first thing anyone sees of a link. Spend the build cycles to make it the page’s own — it’s the cheapest impression you’ll ever buy.

This is one piece of a larger build — fonts, content collections, SEO meta, deploy — all of which I walk through in How to Build a Portfolio Website with Astro. If you came here for the OG cards, the rest of that recipe probably matters to you too.

Where to next

More writing in the journal, or jump back to the beginning.