[ v iet- h ung  ng uyen ]

Generate OG images statically from the post content

September 1, 2023 · 6 min read

When you share a link to any social platforms (such as Twitter or Facebook), viewers can see a preview card with a thumbnail image and few relavent information. That thumbnail image is called an Open Graph image, or OG image for short.

In this tutorial, I will show you my approach to generate OG images from the blog post content from my Astro blog.

Ingredients

Since this blog is written in Astro and all blogs is statically generated, we want all the OG iamges to be generated in build time (i.e. static generation). Here are all the ingredients we need:

Setup

First, we need to install these packages as develper dependencies (I use pnpm as my package manager, but you can use npm or yarn):

pnpm add -D satori satori-html sharp
pnpm add -D satori satori-html sharp

Image generation

Overall, the image generation process is as follows:

  1. Load the data
  2. Create HTML template
  3. Convert HTML template to OG image

To start off, we need a route to serve the OG image. In my case, I want to host these images inside the blog post route, the route name would be /posts/[slug]/og.png. So, we need to create a file posts/[slug]/og.png.ts under the pages directory with these two functions. Also, the path contains dynamic parameters, so we need to export getStaticPaths function to generate the list of paths.

posts/[slug]/og.png.ts
import type { APIContext, InferGetStaticPropsType } from "astro"

export async function getStaticPaths() {
  // ...
}

export async function GET(context: APIContext) {
  // ...
}
import type { APIContext, InferGetStaticPropsType } from "astro"

export async function getStaticPaths() {
  // ...
}

export async function GET(context: APIContext) {
  // ...
}

Load the resource

We utilize the Astro Content Collections API to load the blog post data. We generate the list of paths from post collection, and pass the metadata as props to the GET function.

export async function getStaticPaths() {
  const posts = await getCollection("posts")
  return posts.map((post) => ({
    params: { slug: post.slug },
    props: {
      data: post.data,
    },
  }))
}

type Props = InferGetStaticPropsType<typeof getStaticPaths>
export async function getStaticPaths() {
  const posts = await getCollection("posts")
  return posts.map((post) => ({
    params: { slug: post.slug },
    props: {
      data: post.data,
    },
  }))
}

type Props = InferGetStaticPropsType<typeof getStaticPaths>

Create HTML template

The next step using satori-html package to create the markup with semi-friendly HTML template literal syntax. Luckily, we can use Tailwind CSS to quickly style the markup with the tw attribute supported by satori.

import { html } from "satori-html"

// ...

export async function GET({ props }: APIContext) {
  const { data } = props as Props
  const date = new Intl.DateTimeFormat("en-US", {
    year: "numeric",
    month: "long",
    day: "numeric",
    timeZone: "UTC",
  }).format(data.date)
  const tags = data.tags?.map((tag) => `#${tag}`) || []

  const markup = html` <div tw="flex h-full w-full flex-col justify-between p-16 text-white">
    <div tw="text-4xl font-bold text-teal-400">${logo}</div>
    <div tw="flex flex-col">
      <div tw="text-4xl font-semibold">${data.title}</div>
      <p tw="text-xl opacity-80">${[date, ...tags].join(" · ")}</p>
    </div>
  </div>`

  // More code...
}
import { html } from "satori-html"

// ...

export async function GET({ props }: APIContext) {
  const { data } = props as Props
  const date = new Intl.DateTimeFormat("en-US", {
    year: "numeric",
    month: "long",
    day: "numeric",
    timeZone: "UTC",
  }).format(data.date)
  const tags = data.tags?.map((tag) => `#${tag}`) || []

  const markup = html` <div tw="flex h-full w-full flex-col justify-between p-16 text-white">
    <div tw="text-4xl font-bold text-teal-400">${logo}</div>
    <div tw="flex flex-col">
      <div tw="text-4xl font-semibold">${data.title}</div>
      <p tw="text-xl opacity-80">${[date, ...tags].join(" · ")}</p>
    </div>
  </div>`

  // More code...
}

Adding image to the markup is a little bit tricky. We need to use url function from for the background-image CSS property. The easy way is to upload the image in the cloud storage and use its absolute URL. However, I want to keep the image within the source code, and working with local images is much harder. My approach is to convert the image to base64 string and use it as the url for the background-image property.

Let’s use this image below for the background and save it as og-background.png in the src/assets directory.

Background image for OG image with color gradient and noise texture

Here is the code.

import { html } from "satori-html"
import { readFileSync } from "fs"

const imageBase64 = readFileSync("src/assets/background.png").toString("base64")

export async function GET({ props }: APIContext) {
  // ...

  const markup = html` <div
    tw="flex h-full w-full flex-col justify-between p-16 text-white"
    style="background-image: url('data:image/png;base64,${imageBase64}')"
  >
    <div tw="text-4xl font-bold text-teal-400">${logo}</div>
    <div tw="flex flex-col">
      <div tw="text-4xl font-semibold">${data.title}</div>
      <p tw="text-xl opacity-80">${[date, ...tags].join(" · ")}</p>
    </div>
  </div>`

  // More code...
}
import { html } from "satori-html"
import { readFileSync } from "fs"

const imageBase64 = readFileSync("src/assets/background.png").toString("base64")

export async function GET({ props }: APIContext) {
  // ...

  const markup = html` <div
    tw="flex h-full w-full flex-col justify-between p-16 text-white"
    style="background-image: url('data:image/png;base64,${imageBase64}')"
  >
    <div tw="text-4xl font-bold text-teal-400">${logo}</div>
    <div tw="flex flex-col">
      <div tw="text-4xl font-semibold">${data.title}</div>
      <p tw="text-xl opacity-80">${[date, ...tags].join(" · ")}</p>
    </div>
  </div>`

  // More code...
}

Convert markup to image

The last step is very simple, we just need to convert the SVG to PNG image. Satori requires the font buffers to SVG generation. There are two ways to do this:

In my case, I go with the first option. I use the Inter font with three variants (regular, semi-bold, and bold) and save them in the src/assets/fonts directory.

const regular = readFileSync("src/assets/fonts/Inter-Regular.woff")
const semibold = readFileSync("src/assets/fonts/Inter-SemiBold.woff")
const bold = readFileSync("src/assets/fonts/Inter-Bold.woff")

const fonts = [
  {
    name: "Inter",
    data: regular,
    weight: 400,
  },
  {
    name: "Inter",
    data: semibold,
    weight: 600,
  },
  {
    name: "Inter",
    data: bold,
    weight: 700,
  },
]
const regular = readFileSync("src/assets/fonts/Inter-Regular.woff")
const semibold = readFileSync("src/assets/fonts/Inter-SemiBold.woff")
const bold = readFileSync("src/assets/fonts/Inter-Bold.woff")

const fonts = [
  {
    name: "Inter",
    data: regular,
    weight: 400,
  },
  {
    name: "Inter",
    data: semibold,
    weight: 600,
  },
  {
    name: "Inter",
    data: bold,
    weight: 700,
  },
]

In the end, we want our image has the size of 1200 × 630 pixels.

const DIMENSIONS = {
  width: 1200,
  height: 630,
}

// @ts-ignore
const svg = await satori(markup, {
  ...DIMENSIONS,
  fonts,
})

const png = sharp(Buffer.from(svg)).png()

return new Response(await png.toBuffer(), {
  headers: {
    "Content-Type": "image/png",
  },
})
const DIMENSIONS = {
  width: 1200,
  height: 630,
}

// @ts-ignore
const svg = await satori(markup, {
  ...DIMENSIONS,
  fonts,
})

const png = sharp(Buffer.from(svg)).png()

return new Response(await png.toBuffer(), {
  headers: {
    "Content-Type": "image/png",
  },
})

Result

Voilà! We successfully generated the OG image from the blog post content. Here is an example of generated image from this post.

Generated image with black background

Bonus: Use JSX syntax

Satori supports JSX syntax, so we can use React components to scaffold the image. However, Astro endpoints don’t support JSX syntax (ie. jsx or tsx extension) To work around this, we can create a seperate file with JSX syntax then import it to the endpoint.

First, let’s extract the html markup into a React component. For my case, I use the jsx extension with JSDOC so that the TypeScript compiler won’t throw errors when I use tw attribute for Tailwind styling.

og/image.jsx
/**
 * @param {{
 *  title: string
 *  description: string
 *  imageBase64: string
 * }} props
 */
export function OGImage(props) {
  const { title, description, imageBase64 } = props

  return (
    <div
      tw="flex h-full w-full flex-col justify-between p-16 text-white"
      style={{
        backgroundImage: `url(data:image/png;base64,${imageBase64})`,
      }}
    >
      <div tw="text-4xl font-bold text-teal-500">V_</div>
      <div tw="flex flex-col">
        <div tw="text-4xl font-semibold">{title}</div>
        <p tw="text-xl opacity-80">{description}</p>
      </div>
    </div>
  )
}
/**
 * @param {{
 *  title: string
 *  description: string
 *  imageBase64: string
 * }} props
 */
export function OGImage(props) {
  const { title, description, imageBase64 } = props

  return (
    <div
      tw="flex h-full w-full flex-col justify-between p-16 text-white"
      style={{
        backgroundImage: `url(data:image/png;base64,${imageBase64})`,
      }}
    >
      <div tw="text-4xl font-bold text-teal-500">V_</div>
      <div tw="flex flex-col">
        <div tw="text-4xl font-semibold">{title}</div>
        <p tw="text-xl opacity-80">{description}</p>
      </div>
    </div>
  )
}

After that, we don’t need to use satori-html anymore. We can direct pass JSX element to satori function.

posts/[slug]/og.png.ts
// ... More imports
import { OGImage } from "@/og/image"

export async function GET({ props }: APIContext) {
  // ... More code
  const title = data.title
  const description = [date, ...tags].join(" · ")
  const component = OGImage({ title, description, imageBase64 })

  const svg = await satori(component, {
    ...DIMENSIONS,
    fonts,
  })

  const png = sharp(Buffer.from(svg)).png()

  return new Response(await png.toBuffer(), {
    headers: {
      "Content-Type": "image/png",
    },
  })
}
// ... More imports
import { OGImage } from "@/og/image"

export async function GET({ props }: APIContext) {
  // ... More code
  const title = data.title
  const description = [date, ...tags].join(" · ")
  const component = OGImage({ title, description, imageBase64 })

  const svg = await satori(component, {
    ...DIMENSIONS,
    fonts,
  })

  const png = sharp(Buffer.from(svg)).png()

  return new Response(await png.toBuffer(), {
    headers: {
      "Content-Type": "image/png",
    },
  })
}