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
satori
is the main package to generate SVG images from HTML templates and CSS styles.satori-html
is a plugin forsatori
to construct SVG images with string literals.sharp
is 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",
},
})
}