λ
home posts study uses projects

Open Graph Previews using Figma and Satori

Jun 02, 2024

opengraph astro figma

Intro

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:

OpenGraph Previews using Figma and Satori-20240602180322414.webp

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.

Designing in Figma

I started by making some designs in Figma

Open Graph Previews using Figma and Satori-20240602181759817.webp

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.

Open Graph Previews using Figma and Satori-20240602182426365.webp

Satori Rendering

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.

Open Graph Previews using Figma and Satori-20240602183930528.webp 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>
	`

Static generation using Astro Endpoints

I then created an Astro Endpoint called og.png.ts for each of my collections.

Open Graph Previews using Figma and Satori-20240602190404988.webp

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:

Open Graph Previews using Figma and Satori-20240602201005600.webp

References

LλBS

All systems up and running

Commit c0dacf5, deployed on Nov 18, 2024.