Dynamic OG Image Generation with Satori and Sharp: Guide

By Yanni Papoutsis | 9 min read | 2026-08-03

TL;DR: Satori takes a JSX-like layout, built from a small subset of HTML and CSS (flexbox only, no grid), and converts it into an SVG string. Sharp then takes that SVG and rasterises it into a PNG at whatever size a social platform expects, typically 1200 by 630 pixels. Together they let you generate a unique Open Graph image for every page, with the actual title, category, or author baked into the image, without running a headless browser, which makes the whole pipeline fast enough to run at build time for a static site or on demand from a serverless function. The main things to get right are loading fonts as raw buffers rather than relying on system fonts, keeping the layout within Satori's supported CSS subset, and caching the output so a page with a stable title does not get re-rendered on every request.

What Are Satori and Sharp, and Why Use Them Together for OG Images?

Satori is a library, originally built for Vercel's OG image API, that takes a tree of JSX-like elements and turns it into an SVG. It supports a deliberately limited subset of CSS: flexbox layout, absolute positioning, background colours and images, borders, and text styling, which is enough to build a convincing social card without needing a full browser rendering engine.

Sharp is a separate, general-purpose image processing library built on libvips. On its own it has nothing to do with Satori, but it is the fastest common way to take the SVG that Satori produces and convert it into a PNG or JPEG at a specific size, which is the format every social platform actually expects when it reads Open Graph meta tags.

Used together, the two cover the full path from "a title and a description" to "a finished image file": Satori handles layout and text, Sharp handles rasterisation and format conversion. Neither one depends on Puppeteer or a Chromium binary, which is the detail that makes this approach practical in serverless and edge environments where installing a full browser is slow, heavy, or simply unsupported.

Why Generate OG Images Dynamically Instead of Designing Them by Hand?

A hand-designed OG image works fine for a handful of static pages: a homepage, an about page, maybe a pricing page. It stops working the moment a site publishes more than a few dozen pages, because nobody is opening a design tool to make a bespoke card for every blog post.

A dynamic image, generated from the same title, description, and category data that already exists in a page's front matter, gives every single page a correctly branded, on-topic social card without a manual design step. The image updates automatically if the title changes, stays visually consistent across hundreds of pages because it comes from one template, and costs nothing extra per page beyond the render time.

How Does Satori Turn a Layout into an Image?

You describe the layout as a plain JavaScript object tree, the same shape React elements take, without actually needing React itself:

const template = {
  type: "div",
  props: {
    style: {
      height: "100%",
      width: "100%",
      display: "flex",
      flexDirection: "column",
      justifyContent: "center",
      padding: "80px",
      backgroundColor: "#0f172a",
      color: "#f8fafc",
    },
    children: [
      {
        type: "div",
        props: {
          style: { fontSize: 28, color: "#38bdf8", marginBottom: 20 },
          children: "yanni.uk",
        },
      },
      {
        type: "div",
        props: {
          style: { fontSize: 64, fontWeight: 700, lineHeight: 1.2 },
          children: "Dynamic OG Image Generation with Satori and Sharp",
        },
      },
    ],
  },
};

Satori walks this tree, resolves the flexbox layout the same way a browser would for the supported subset of CSS, and outputs an SVG string. There is no DOM and no browser involved; the layout engine is implemented directly inside the library.

How Do You Set Up Satori for OG Image Generation?

Install the two packages:

{
  "dependencies": {
    "satori": "^0.10.0",
    "sharp": "^0.33.0"
  }
}

Then write a function that takes page data and returns a finished PNG buffer:

import satori from "satori";
import sharp from "sharp";
import { readFileSync } from "fs";

const interBold = readFileSync("./fonts/Inter-Bold.ttf");
const interRegular = readFileSync("./fonts/Inter-Regular.ttf");

async function renderOgImage({ title, category }) {
  const svg = await satori(
    {
      type: "div",
      props: {
        style: {
          height: "100%",
          width: "100%",
          display: "flex",
          flexDirection: "column",
          justifyContent: "space-between",
          padding: "80px",
          backgroundColor: "#0f172a",
        },
        children: [
          {
            type: "div",
            props: { style: { fontSize: 28, color: "#38bdf8" }, children: category },
          },
          {
            type: "div",
            props: {
              style: { fontSize: 60, fontWeight: 700, color: "#f8fafc" },
              children: title,
            },
          },
        ],
      },
    },
    {
      width: 1200,
      height: 630,
      fonts: [
        { name: "Inter", data: interRegular, weight: 400, style: "normal" },
        { name: "Inter", data: interBold, weight: 700, style: "normal" },
      ],
    }
  );

  return sharp(Buffer.from(svg)).png().toBuffer();
}

satori returns the SVG as a string; sharp wraps it, rasterises it at the dimensions already baked into the SVG, and produces a PNG buffer ready to write to disk or return from an API route.

How Do You Wire This into an API Route or a Build Step?

Two integration patterns cover most cases. For a site with server-rendered or edge routes, expose an endpoint that generates the image on request, driven by query parameters or a page slug:

export async function GET({ params }) {
  const page = await getPageData(params.slug);
  const png = await renderOgImage({ title: page.title, category: page.category });
  return new Response(png, {
    headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=31536000, immutable" },
  });
}

For a fully static site, generate every image once at build time instead, looping over the content collection and writing a PNG per page into the build output. On an Astro project deployed the way described in astro-cloudflare-pages-guide, this fits naturally as a build script that runs before the static output gets pushed to the CDN, since every image already exists as a file by the time a visitor requests the page.

How Do You Handle Fonts Correctly in Satori?

This is the step most setups get wrong on the first attempt. Satori does not have access to system fonts the way a browser does; every font used in the template has to be loaded explicitly as a buffer and passed into the fonts option, as shown above. Skip this and text renders with a fallback that looks nothing like the intended design, or fails to render at all.

Keep font files in the repository, a .ttf or .otf file per weight actually used, rather than fetching them from a remote URL on every render, since a network fetch inside an image-generation function adds latency and a new failure point for something that should be fast and reliable. If the generation endpoint runs on a platform where secrets or configuration values are needed, such as a signed URL for a font stored in object storage, store that value the way covered in vercel-environment-variables-guide rather than hardcoding it into the function.

How Do You Cache Generated OG Images So You're Not Rendering on Every Request?

Rendering an image on every single request is wasteful when the underlying title and category rarely change. Two layers of caching handle this properly:

For a site that publishes on a schedule and rebuilds on every deploy, wiring the image generation step into the same trigger that rebuilds everything else keeps things simple. The build hook pattern in netlify-build-hooks-automation covers triggering a rebuild programmatically, and the same trigger that regenerates the site can regenerate its images in the same pass.

How Does This Fit into a Broader Build and Deploy Pipeline?

Treat OG image generation as one more step in the pipeline that already builds and deploys the site, not a separate manual process. The general pattern for structuring that pipeline is covered in cicd-pipelines-claude-projects; the image generation script runs alongside the sitemap and llms.txt generation steps, before the build output gets deployed.

If the repository is on GitHub, the same push that triggers a deploy through github-integration-claude can trigger the image generation step first, so every deploy ships with correct, up-to-date images rather than images generated by a separate, easy-to-forget process. The initial platform connection, if the site is hosted on Netlify, is covered in connect-netlify-claude.

Do You Need to Worry About Security Headers for an Image Endpoint?

If the image generation route is a live API endpoint rather than a build-time step, treat it like any other endpoint that accepts input and returns a response: validate the slug or parameters against known pages rather than rendering arbitrary user-supplied text, and rate-limit it, since an unprotected render endpoint is an easy target for someone trying to run up compute costs by requesting thousands of unique images. The general header configuration covered in security-headers-csp-hsts-x-frame still applies to the routes serving the generated images, particularly X-Content-Type-Options, since the endpoint returns binary image data that should never be sniffed as anything else.

How Do You Test and Debug an OG Image Template?

Render the SVG step on its own before adding Sharp into the mix, since a broken layout is much easier to read as raw SVG markup than as a PNG that just looks wrong:

node -e "
const satori = require('satori').default;
const fs = require('fs');
satori({ type: 'div', props: { children: 'test' } }, { width: 1200, height: 630, fonts: [] })
  .then((svg) => fs.writeFileSync('debug.svg', svg));
"

Open the resulting SVG directly in a browser tab to check layout and spacing before worrying about the PNG conversion step at all. Once the SVG looks right, the Sharp conversion is unlikely to introduce new layout problems, since it only rasterises what Satori already produced.

Frequently Asked Questions

Why not just use a headless browser like Puppeteer to screenshot a real HTML page?

That works, but it is significantly heavier: a full Chromium binary, more memory, and slower cold starts, which matters a lot in a serverless or edge function that needs to respond quickly. Satori and Sharp produce the same end result, a PNG image, without needing a browser at all, which is why they have become a default choice for OG image generation on platforms like Vercel and Cloudflare.

Does Satori support the same CSS I would use in a normal stylesheet?

No, only a subset: flexbox layout, absolute positioning, colours, borders, and basic text styling. Grid layout is not supported, and neither are most CSS features outside that core set. Design the template with these constraints in mind from the start rather than building something in regular CSS and discovering later which parts do not translate.

What image dimensions should I actually generate?

1200 by 630 pixels is the safe default that displays correctly across Open Graph consumers including Facebook, LinkedIn, and Slack unfurls, as well as Twitter's large summary card format. Generating at that fixed size, rather than trying to serve different sizes per platform, covers the overwhelming majority of cases without added complexity.

Can this run entirely at build time for a fully static site with no server?

Yes, and for a static site that is usually the better choice over an on-demand API route: loop over every page in the content collection during the build, render each image once, and write it to the output directory alongside the HTML. The image then deploys as a static file with no render cost per request and no caching logic needed at all, since it already exists before anyone asks for it.