Ever seen those nice image previews when you share a link on Twitter, LinkedIn, Mastodon, etc? Those are called OpenGraph images, more on that in a minute. Creating those images, is however, time-consuming. Especially if you are focused on writing. I used to use Unsplash images, which are free, up until the moment I shared this very important issue, that I’ve opened on GitHub, to my LinkedIn followers.
The image you see when you share any GitHub issue—is auto generated, and contains the information about the issue itself. Ain’t this cool? We can do the same!
What is OpenGraph?
OpenGraph is a protocol that enables any web page to become a rich object in a social graph - https://ogp.me/
The idea was created, and popularized by Facebook, but in a nutshell—it’s a bunch of meta
tags, that different platforms such as Twitter, or LinkedIn—can read, and generate preview cards based on them. They were initially made to index the content inside a graph, but we care only about the preview card.
You can visit the official website to learn more about all the meta tags. We, however, will focus only on one: og:image
. And because back in the days when Facebook was popular, Twitter tried to compete with it, it’s no surprise that Twitter has its own set of meta tags. For our use case, we will focus on twitter:image
meta tag. You can read more about Twitter tags here. Also keep in mind that Twitter can parse OpenGraph tags, in case Twitter tags are not present.
How to auto-generate the image
Meet satori
. Satori is an extremely cool library that converts HTML and CSS into SVG images. One thing to keep in mind is that satori
supports a sub-set of the CSS properties, so you can’t go too wild. Refer to this table to learn more.
satori
also supports JSX, but I don’t see a reason to bring an entire Frontend framework, just to render one image. Instead, we will use satori-html
which is an HTML adapter this will allow us to write plain HTML.
First, install the libraries:
yarn add satori satori-html
As an example, we will use my website’s OpenGraph image which can be found here. I’ll spit the code first, and then we will discuss it.
const markup = html`
<div
style="width: 1200px; height: 630px; display: flex; flex-direction: column; flex-wrap: wrap; justify-content: center; align-items: center; padding: 0 24px;"
>
<img
src="https://www.yieldcode.blog/images/cover.jpg"
width="500"
height="500"
style="width: 150px; height: 150px; border: 1px solid white; border-radius: 50%;"
/>
<h1 style="color: rgb(17,24,39); font-size: 5.5rem; font-weight: 700">
${SITE_AUTHOR}
</h1>
<h2 style="color: rgb(82,82,82); font-size: 2.5rem; font-weight: 700">
${SITE_AUTHOR_TITLE}
</h2>
<h3 style="color: rgb(82,82,82); font-size: 1.5rem; font-weight: 400">
${SITE_DESCRIPTION}
</h3>
</div>
`;
const svg = await satori(markup, {
width: 1200,
height: 630,
embedFont: true,
fonts: [
{
name: "Inter",
data: interRegularData,
weight: 400,
style: "normal",
},
{
name: "Inter",
data: interBoldData,
weight: 700,
style: "normal",
},
],
});
The first thing we notice straight away, is that satori
does not support classes, and instead requires the usage of style
attributes.
The html`
thingy comes from satori-html
:
import { html } from "satori-html";
Handling fonts
The other thing we notice—is how fonts are handled. Since we do not render complete HTML page, fonts are not handled through the head
tags. Instead, we need to load the fonts manually, and feed them to satori
. In my case I use the Inter
font in its regular and bold variations. This means that I need to get the font files and read them.
import fs from "fs/promises";
const [interRegularData, interBoldData] = await Promise.all([
fs.readFile("./src/assets/fonts/inter/Inter-Regular.woff"),
fs.readFile("./src/assets/fonts/inter/Inter-Bold.woff"),
]);
To SVG or not to SVG?
We could stop there, but Twitter being Twitter:
URL of image to use in the card. Images must be less than 5MB in size. JPG, PNG, WEBP and GIF formats are supported. Only the first frame of an animated GIF will be used. SVG is not supported.
So for the sake of compatibility, it would be best to convert the SVG to PNG. Meet sharp
—a high performance Node.js image processing library. Sharp can do a lot of things such as: converting one type to another, resize, and rotate images.
The conversion is straight forward:
const png = await sharp(Buffer.from(svg)).png().toBuffer();
That’s it. What’s left now, is sending the png
as a body of the response, together with the correct header: Content-Type: image/png
. If you are familiar with express-like libraries, this should be easy for you.
Bonus - Astro
I <3 Astro. I think it’s a great framework to build static websites. And it’s not a surprise that all my websites, including this blog, are powered by Astro. It can be a bit tricky to add this functionality to Astro, so I decided to dedicate a section for this.
Astro has a notion of APIRoute
. APIRoute
is a away to generate non HTML pages, such as: RSS feeds, sitemaps, and images. Following our example image, we would like to create a new file, inside our pages
folder, named og.png.ts
. The content of this file is very simple:
import type { APIRoute } from "astro";
export const get: APIRoute = async function get() {
// read fonts
// created markup
// generate svg with satori
// convert svg to png with sharp
return new Response(png, {
status: 200,
headers: {
"Content-Type": "image/png",
},
});
};
And that’s it. Upon building your static website, Astro will also generate a file named og.png
which will be the image that satori
generated from your HTML markup, and sharp
converted to PNG.
Bonus 2
It’s even possible to generate multiple images for an Astro collection. The same way Astro generates HTML pages from your collection, you can generate a cover image for each post. Suppose you have your posts inside src/pages/[slug]/index.astro
, you can create a new file named src/pages/[slug]/cover.png.ts
with the following content:
export async function getStaticPaths() {
const posts = await getCollection("post");
return posts.map((entry) => ({
params: { slug: entry.slug },
props: { post: entry },
}));
}
export const get: APIRoute = async function get({ props: { post } }) {
// do whatever you want with post - extract the content, excerpt, date, tags
// load fonts
// create html markup
// create svg with satori
// create png with sharp
// send response
};
One really final touch—don’t forget to add the proper meta tags:
<meta property="og:image" content="https://www.yieldcode.blog/og.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://www.yieldcode.blog/og.png" />