DEV Community

Cover image for How Automated OG Images Work on my Website
Jay Wilson
Jay Wilson

Posted on • Originally published at heyjaycodes.com

How Automated OG Images Work on my Website

When you share a post from heyjaycodes.com on Twitter, iMessage, or Slack, a preview card appears with the post title, excerpt, and date. That image is generated automatically for every post at build time. No Photoshop, no Figma, no manual step.

Here's how it works.

The stack

Satori is the same library Vercel uses for their OG image generation. It takes a virtual DOM description and a font config, and outputs an SVG string. Resvg turns that into a PNG binary.

The route

The image generation lives at src/pages/og/[...slug].png.ts. Astro treats the .png as part of the route, so generated files end up at URLs like /og/2026/04/16-automated-og-images.png.

The file has two exports: getStaticPaths and GET.

getStaticPaths runs at build time and tells Astro which images to generate. It loads every post from the content collection and maps each one to a route param plus the metadata needed for the image:

export async function getStaticPaths() {
  const posts = await getCollection("posts");
  return posts.map((post) => ({
    params: { slug: post.id },
    props: {
      title: post.data.title,
      excerpt: post.data.excerpt,
      date: post.data.date,
    },
  }));
}
Enter fullscreen mode Exit fullscreen mode

GET receives those props and builds the image. It reads the font files, passes a virtual element tree to satori, and pipes the resulting SVG through resvg to get a PNG:

export async function GET({ props }) {
  const { title, excerpt, date } = props;

  const regularFont = readFileSync(resolve("src/fonts/JetBrainsMono-Regular.ttf"));
  const boldFont = readFileSync(resolve("src/fonts/JetBrainsMono-Bold.ttf"));

  const svg = await satori(
    { type: "div", props: { /* layout tree */ } },
    { width: 1200, height: 630, fonts: [...] }
  );

  const png = new Resvg(svg).render().asPng();
  return new Response(png, { headers: { "Content-Type": "image/png" } });
}
Enter fullscreen mode Exit fullscreen mode

The design

Every image is 1200x630px, which is the standard OG image size.

Example of OG-Image

  • post.data.title is the large bold white text
  • post.data.excerpt is the smaller gray text below the title
  • post.data.date is the small dimmed text at the bottom, passed through formatDate()
  • The domain is hardcoded to heyjaycodes.com in amber

Everything uses JetBrains Mono, which matches the site's monospace aesthetic.

Adding it to each post

Each post page passes its OG image path to the Base.astro layout:

<Base ogImage={`/og/${post.id}.png`} ogType="article">
Enter fullscreen mode Exit fullscreen mode

Base.astro constructs the full URL using Astro.site and injects the standard Open Graph and Twitter Card meta tags. Pages without a post-specific image fall back to the static /og-image.png in public/.

Why this works well

I considered two alternatives: making images by hand or using a hosted service like Cloudinary. Making images by hand means extra work every time you publish, with no guarantee they'll look consistent. A hosted service solves consistency but introduces a dependency and usually costs money.

Satori + resvg at build time avoids both problems. The images are static files in dist/, so there's no serverless function to maintain and no runtime latency. It runs locally, costs nothing, and produces the same output every time.

Top comments (0)