Automate Open Graph Images

Written by Basile Samel.

Published Oct 9, 2024. Last updated Oct 9, 2024.

Social networks use Open Graph images to display external links, so getting them right is key to increasing conversion rates.

But it can be difficult to manually design each social card. In the case of a blog post for example, even if you use a ready-made Figma template, you still need to customize the image title, add subtle branding to increase brand awareness, maybe add the author’s picture to raise trust, etc. Different social websites also use different image resolutions and formats, and you might want to optimize them to more easily grab attention.

For all these reasons, you should look into automating the creation of your Open Graph images, and Astro.js is amazingly easy to use for that!

1. Adding An OG Card

If each blog post has a specific image to use for their social card―for example, as unique background images―you need to make it available in your content schema:

/src/content/config.ts

import { z, defineCollection } from 'astro:content';

const blogs = defineCollection({
    type: 'content',
    schema: ({ image }) => z.object({
        title: z.string(),
        card: image()
    }),
});

export const collections = {
  blogs
};

In this example, title is a string representing the title of the blog post, and card an image used for the Open Graph card.

AstroJS will automatically take care of parsing and making the image available to the rest of your static pages or endpoints. Neat.

We can then just update our Markdown frontmatter to include a path to our base image:

/src/content/blogs/blog-slug/index.md

---
title: "" 
card: "./card.webp"
---

2. Create A Static Endpoint

Typically a blog post HTML page will include these tags:

<meta property="og:title" content="Example Blog Post" />
<meta property="og:type" content="article" />
<meta property="og:image" content="https://example.com//blog/slug/card.jpg" />
<meta property="og:url" content="https://example.com//blog/slug" />
<meta property="og:description" content="A blog post with a valid open graph card." />

If we were to do this manually, we would put our social card in the /public folder.

Instead, we can serve social cards at build time:

/src/pages/blog/[…slug]/card.webp.ts

import { getCollection } from 'astro:content';
import fs from "node:fs/promises"

const dirname = import.meta.dirname
 
export async function GET({ props }) {
  const { entry } = props;

  const file = await fs.readFile(entry.data.card.src)
 
  return new Response(file, {
    headers: {
      "Content-Type": "image/jpg"
    }
  })
}
 
export async function getStaticPaths() {
  const blogEntries = await getCollection('blogs');

  return blogEntries.map(entry => ({
    params: { slug: entry.slug }, props: { entry },
  }));
}

This piece of code tells AstroJS to generate a GET endpoint for each blog post to get the Open Graph image corresponding to the Markdown markup.

Image Generator Function With Sharp.JS

We can do better, however.

AstroJS already relies on sharp.js to optimize images. We can use the same library to create new ones.

Why not use a library like satori.js, you might ask.

satori generates SVG images from JSX / HTML, so you can just use CSS properties to create nice flex layouts. You can then use the output SVG with sharp to turn it into a usable jpeg image.

I prefer to skip this step and rely on sharp directly to create images from scratch programmatically. It’s kind of painful to align elements properly, but it really helps keep my builds shorter when there are hundreds of social cards to generate. It’s also annoying in satori how you need to convert your images to base64 encoding to use in HTML to avoid additional network calls.

Here is an example of a social card generator:

/src/pages/blog/[…slug]/card.webp.ts

import sharp from 'sharp'

// ...

async function generateFile(post) {
  const logo = await sharp(/*LOGO_PATH*/).resize(100).toBuffer()

  const title = await sharp({
    text: {
      text: `<span foreground="black" background="white" size="x-large">${post.data.title}</span>`, 
      width: 300,
      rgba: true,
      dpi: 100 
    }
  }).webp().toBuffer()

  const cta = await sharp({
    text: {
      text: '<span foreground="black" background="white" size="small">clowdr.com</span>', 
      width: 300,
      rgba: true,
      dpi: 100 
    }
  }).webp().toBuffer()

  return sharp({
      create: {
        width: 600,
        height: 400,
        channels: 4,
        background: { r: 255, g: 255, b: 255, alpha: 1 }
      }
  })
  .composite([
    { input: logo, top: 150, left: 20 },
    { input: title, top: 150, left: 150 },
    { input: cta, top: 375, left: 475 },
  ])
  .jpeg().toBuffer()
}

I use sharp to combine three images (one base image using my website’s logo + two text images) over a white background.

Note that you can customize each image’s position relative to its parent, so it only takes a few tries to get it right. You can also use alignment and justification properties to easily center your text, and the width property can be used to limit the width of the text overlay.

I just need to include my generator function in the endpoint:

- const file = await fs.readFile(entry.data.card.src)
+ const file = await generateFile(entry)

And then we can visit our local development web server http://localhost:4321/blog/BLOGPOST-SLUG/card.webp to see the result in real-time.

And voilà.

This is all it takes to generate social cards.

Of course you can always go further and optimize as you go:

Susbcribe to the newsletter