When you post a link on Discord, Telegram, Twitter, etc, their servers use metadata that’s defined on the page to generate a preview for your link:
This is mainly powered by Open Graph, which is a protocol made by Facebook that defines some special meta tags that are used by social networks/messaging apps.
I was looking for a way to dynamically generate these images at build time for my blog posts and came across this and this article, which helped in my implementation.
I started by making some designs in Figma
Instead of manually reimplementing the one I wanted using HTML & CSS, I decided to export it to SVG. To do that, I used the SVG Export Figma plugin. Before doing that, I hid the text layers, as they’ll be interpolated in my Satori template.
I then copied the generated SVG code into Vercel’s OG Playground which uses Satori under the hood. Satori is a JS library that converts HTML / CSS to SVG. I used the playground to verify my experiments in real-time as it updates the preview as soon as you change the code on the left.
In the OG Playground, I added the <p>
tags for the post title and subtitle. I also made sure to set the container width/height to 1200x630 as that’s the recommended Open Graph preview size. The fonts slightly mismatch with the original Figma design, but that’s because DM Sans (the font I use) isn’t imported in the playground.
The OG Playground (and Satori) uses JSX or React-elements-like syntax
import satori from 'satori'
const svg = await satori(
<div style={{ color: 'black' }}>hello, world</div>,
{
width: 600,
height: 400,
fonts: [
{
name: 'Roboto',
data: robotoArrayBuffer,
weight: 400,
style: 'normal',
},
],
},
)
// or
await satori(
{
type: 'div',
props: {
children: 'hello, world',
style: { color: 'black' },
},
},
options
)
Astro doesn’t support JSX out of the box and requires extra configuration to allow that. Instead of manually building the React-elements-like tree, I used satori-html. This library takes in raw HTML as a string and converts it to the aforementioned tree.
import { html } from "satori-html"
export const buildHTML = (
title: string,
date: Date,
slug: string,
) => {
return html`<div style="...">
<svg
xmlns="http://www.w3.org/2000/svg"
width="1200"
height="630"
fill="none"
viewBox="0 0 1200 630"
>
...
</svg>
<p style="...">${title}</p>
<p style="...">
-rw-r--r-- 1 simo nerv 1337
${date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
${slug}.md
</p>
</div>
`
I then created an Astro Endpoint called og.png.ts
for each of my collections.
import { type CollectionEntry, getCollection } from "astro:content"
import { buildHTML, buildOG } from "../../../og.ts"
import { filenameToTitle } from "../../../filenameToTitle.ts"
interface Props {
params: { slug: string }
props: { post: CollectionEntry<"blog"> }
}
export async function GET({ props }: Props) {
const { post } = props
const buffer = await buildOG(
buildHTML(filenameToTitle(post.id), post.data.date, "blog", post.slug),
)
return new Response(buffer, {
headers: { "Content-Type": "image/png" },
})
}
// Invoke the /blog/[slug]/og.png.ts endpoint for each blog post
export async function getStaticPaths() {
const posts = await getCollection("blog")
return posts.map((post) => ({
params: { slug: post.slug },
props: { post: post },
}))
}
The buildOG
function takes in the generated tree from satori-html and returns a PNG preview of the post. To convert the SVG to PNG, I used resvg-js.
import { readFile } from "fs/promises"
import satori from "satori"
import { Resvg } from "@resvg/resvg-js"
// ...
const dmSans = await readFile("./public/fonts/DM-Sans.ttf")
export const buildOG = async (input: ReturnType<typeof html>) => {
const svg = await satori(input, {
width: 1200,
height: 630,
fonts: [
{
name: "DM Sans",
data: dmSans,
weight: 700,
style: "normal",
},
],
})
const resvg = new Resvg(svg)
return resvg.render().asPng()
}
Astro will then go over every post in each collection using the getStaticPaths
function and render a file called og.png
in the respective /${collection}/[slug]/
directory.
$ tree ./dist/projects
.
├── discord-friends
│ ├── index.html
│ └── og.png
├── findaudit
│ ├── index.html
│ └── og.png
├── flappy-ai
│ ├── index.html
│ └── og.png
├── index.html
├── piral
│ ├── index.html
│ └── og.png
├── push
│ ├── index.html
│ └── og.png
├── sugoku
│ ├── index.html
│ └── og.png
└── tic-tac-toe-solver
├── index.html
└── og.png
8 directories, 15 files
Then this Metadata.astro
component adds the necessary <meta>
tags:
---
interface Props {
title: string
type: "website" | "article"
publishedTime?: string
canonicalUrl: string
description: string
image: string
}
const { title, description, image, canonicalUrl, type, publishedTime } = Astro.props
---
<link rel="canonical" href={canonicalUrl} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:image" content={image} />
<meta property="og:type" content={type} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta name="twitter:image" content={image} />
<meta name="description" content={description} />
{publishedTime && <meta property="article:published_time" content={publishedTime} />}
In my /blog/[slug]/index.astro
file (which statically builds a page for each slug/entry), I render the Metadata.astro
component:
...
<Blog filename={entry.id} {...entry.data}>
<Metadata
slot="head"
title={filenameToTitle(entry.id)}
description={entry.data.excerpt}
image={`${Astro.site}blog/${entry.slug}/og.png`}
canonicalUrl={`${Astro.site}blog/${entry.slug}`}
publishedTime={entry.data.date.toISOString()}
type="article"
/>
<Content />
</Blog>
...
As each thumbnail is called og.png
in the same directory as the html page, referring to the${Astro.site}/blog/[slug]/og.png
image, will result in the following meta
tag:
<meta property="og:image" content="https://simo.sh/blog/uses/og.png">
The final result looks like this: