Generating OpenGraph images with Astro

Providing OpenGraph images for websites enables other tools to render rich previews when your content is shared.

I used to manually add an image for each of my posts and embedded it using the astro-seo integrations. This approach works well in general, but I wanted to have a bit more branding with these images. Of course, I could have put some more effort into creating these images, using a Photoshop template and manually adding titles on top but, well, that would be pretty boring, right?

The generated OpenGraph image for this post.

With Astro, it’s possible to create Static File Endpoints, which I use to create my RSS feed for example. I tried to use this to create an image endpoint instead, utilizing the @vercel/og package to generate an image response for a given JSX construct.

I’m using Content Collections for my gallery and thoughts areas. So far, my pages structure looked like this:

/thoughts/index.astro
/thoughts/[...slug].astro

I moved the detail page to a subfolder and renamed it to index.astro, so existing URLs for post will remain working. Next to this, I created a new file for the image. The new structure now looks like this:

/thoughts/index.astro
/thoughts/[slug]/index.astro
/thoughts/[slug]/og.png.ts

To get these image generated for every post in the thoughts collection, I’m utilizing the getStaticPaths function, Astro provides:

import { type CollectionEntry, getCollection } from "astro:content";

export async function getStaticPaths() {
  const thoughts = await getCollection("thoughts");
  return thoughts.map((thought) => ({
    params: { slug: thought.slug },
    props: { thought },
  }));
}

Now comes the fun part - generating an image. We add a GET handler for this endpoint, receiving the individual post data via props. Inside, we use the @vercel/og package, to create an ImageResponse. Sadly Astro does not support JSX inside these endpoints, so I used the object syntax for React content:

export async function GET({ props }: Props) {
  const { thought } = props;

  const html = {
    type: "div",
    props: {
      children: "Hello World",
      tw: "w-full h-full flex items-center justify-center bg-white",
    }
  };

  return new ImageResponse(html, {
    width: 1200,
    height: 630
  });
}

Using the tw prop, it’s even possible to utilize Tailwind CSS for styling the output. This is the result:

First \

Here is an extended example, using custom fonts and images:

import fs from "fs";
import path from "path";
import logo from "./logo.png";

const loadImage = (src: string): Buffer | undefined => {
  return fs.readFileSync(
    process.env.NODE_ENV === "development"
      ? path.resolve(src.replace(/\?.*/, "").replace("/@fs", ""))
      : path.resolve(src.replace("/", "dist/")),
  );
};

export async function GET({ props }: Props) {
  const { thought } = props;

  const FigtreeRegular = fs.readFileSync(
    path.resolve("./public/fonts/Figtree-Regular.ttf"),
  );

  const logoImage = loadImage(logo?.src);

  const html = {
    type: "div",
    props: {
      tw: "w-full h-full flex flex-col items-center justify-center bg-white",
      children: [
        {
          type: "div",
          props: {
            children: thought.data.title,
            style: {
              fontFamily: "Figtree Regular"
            }
          }
        },
        {
          type: "img",
          props: {
            src: logoImage.buffer
          }
        }
      ]
    }
  };

  return new ImageResponse(html, {
    width: 1200,
    height: 630,
    fonts: [{
      name: "Figtree Regular",
      data: FigtreeRegular.buffer,
      style: "normal",
    }]
  });
}

From there, it’s only a matter of creativity to come up with a nice design. Finally, add this new image path as OG image.