Vaibhav Shinde
Published

Generating OG images in Astro

How this blog builds Open Graph cards at build time with Satori and resvg.

Every post on this site gets a share card for free. No Figma export, no hand-written PNGs. The image is generated when the site builds.

I prefer build time over an on-demand API route with caching. This blog is small, so rendering a PNG per post barely shows up in the build. Posts only change when I deploy anyway, so paying for compute on the first crawler hit (and worrying about cache keys, cold starts, and stampedes) is wasted work. A static PNG on the CDN is cheaper for me, always warm, and the first share is as fast as any other asset. It also keeps @resvg/resvg-js in Node at build time instead of fighting native addons or WASM on the edge.

Build time fits when the set of cards is known up front and grows slowly: a personal site, docs, a marketing page per release. I’d reach for an on-demand route if cards were user-generated, previewed before publish, or numbered in the thousands with a build budget I cared about.

The endpoints

There are two routes:

  • /og.png for the site-wide card (home, listing pages)
  • /blog/[slug]/og.png for each post

Both are Astro API routes with prerender = true, so they become static PNGs in dist/. The per-post route walks the content collection and passes title, description, and date into the renderer:

export const prerender = true;

export const getStaticPaths = (async () => {
  const posts = await getVisibleBlogPosts();

  return posts.map((post) => ({
    params: { slug: post.id },
    props: {
      title: post.data.title,
      description: post.data.description,
      date: formatOgDate(post.data.pubDate, post.data.updatedDate),
    },
  }));
}) satisfies GetStaticPaths;

export const GET: APIRoute<Props> = async ({ props }) => {
  const png = await renderOgPng({
    title: props.title,
    description: props.description,
    date: props.date,
  });

  return pngResponse(png);
};

The site-wide /og.png route is the same idea with fixed title and description.

Satori, then resvg

The pipeline:

  1. Satori turns a JSX layout into SVG
  2. resvg rasterizes that SVG to PNG at 1200×630
export async function renderOgPng(input) {
  const [fonts, avatarSrc] = await Promise.all([
    loadOgFonts(),
    loadAvatarDataUrl(),
  ]);

  const svg = await satori(ogTemplate({ ...input, avatarSrc }), {
    width: 1200,
    height: 630,
    fonts,
  });

  const resvg = new Resvg(svg, {
    fitTo: { mode: 'width', value: 1200 },
  });

  return resvg.render().asPng();
}

Fonts are read from @fontsource (Inter + Libre Baskerville), same families the site uses. The avatar is the profile photo, cached as a data URL so Satori can draw it without a network fetch.

The template

Satori accepts a limited subset of HTML/CSS, written as JSX. Everything is inline styles, and flexbox is the main layout tool. A simplified version of the card looks like this:

/** @jsxImportSource satori/jsx */

export function ogTemplate({ title, description, date, avatarSrc }) {
  return (
    <div
      style={{
        width: '100%',
        height: '100%',
        display: 'flex',
        flexDirection: 'column',
        justifyContent: 'space-between',
        padding: 80,
        backgroundColor: '#0c0a09',
        color: '#fff7ed',
      }}
    >
      <div style={{ display: 'flex', flexDirection: 'column', gap: 28 }}>
        <div style={{ display: 'flex', fontSize: 24, color: '#fdba74' }}>
          {date}
        </div>
        <div
          style={{
            display: 'flex',
            fontSize: 60,
            fontFamily: 'Libre Baskerville',
            lineHeight: 1.15,
          }}
        >
          {title}
        </div>
        <div
          style={{
            display: 'flex',
            fontSize: 28,
            fontFamily: 'Inter',
            color: '#a8a29e',
          }}
        >
          {description}
        </div>
      </div>

      <div
        style={{
          display: 'flex',
          alignItems: 'center',
          gap: 16,
          fontFamily: 'Inter',
          fontSize: 24,
        }}
      >
        <img
          src={avatarSrc}
          width={44}
          height={44}
          style={{ borderRadius: 9999 }}
        />
        Vaibhav Shinde
      </div>
    </div>
  );
}

The real template adds background glows, a light grid, borders, and title scaling when the string gets long. Satori is picky: use real elements instead of fragments for layered backgrounds, and stick to display: 'flex' on almost everything.

Colors and sizing live in one theme object, so tweaking the look is a single file change.

Wiring it into pages

Each post page points its Open Graph and Twitter image meta tags at the generated path:

const image = `/blog/${post.id}/og.png`;
// → <meta property="og:image" content="https://vaibhavshn.com/blog/.../og.png" />

This post uses that same URL as its heroImage, so the card in the header is the one social platforms get when the link is shared.