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:
- Satori by Vercel is a powerful tool to generate images from HTML templates and CSS styles.
- Astro Content Collections featured in Astro v2 is a tools to managing content, such as blog posts, in Astro.
- Astro endpoints allows us to create endpoints that serve any types of data other than HTML pages. In this case, we need to create PNG images.
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
satoriis the main package to generate SVG images from HTML templates and CSS styles.satori-htmlis a plugin forsatorito construct SVG images with string literals.sharpis a high perfomance NodeJS image processing tool, and we will use it to convert SVG to PNG.
Image generation
Overall, the image generation process is as follows:
- Load the data
- Create HTML template
- 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.
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.

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:
- Load the font files from the file system (using Node.js
fs) - Load the font files from the web (using
fetch)
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.

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.
/**
* @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.
// ... 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",
},
})
}