Generating dynamic social media images with SvelteKit and resvg-js
Table of contents
Back in 2021 I wrote about how I used puppeteer in a Netlify function to generate on-demand social media images for pages on this site.
If you’re not familiar with the technique, the general process is:
- You add a
<meta property="og:image" content="SOME URL" />
tag to your HTML - That URL points to whatever on-demand “serverless” thing like a Netlify function or an AWS Lambda
- That function looks up some metadata (say, title and description) from your page and spins up
puppeteer
(a headless, programmable web browser) - Puppeteer renders a pretty HTML template with that metadata. Because you have a full browser at your disposal, that template can display whatever a browser can, so let your imagination run wild.
- Puppeteer takes an actual screenshot of that rendered markup.
- The serverless function returns that screenshot
With that in place, social media sites can get a compelling image for whatever URL anyone shares, and you don’t have to manually create them.
It’s pretty cool! GitHub, in particular, has turned this into something of an art.
However, there are several drawbacks:
- To get
puppeteer
working in Netflix functions (or AWS Lambdas), you need to use a special ultra-small version of chromium and know the incantations to get it working. - Spinning up
puppeteer
on-demand, taking a screenshot, and processing it takes time. Netlify functions have strict requirements on run times, and if you take too long (currently more than 10 seconds), it’ll fail. - If you’re dependent on the network in any way to style your template, the network could fail! I not infrequently ended up with wonky images because webfonts timed out or whatever. It’s temporary, but random and annoying.
- Even if everything goes fast enough for the function to return data, certain social media sites don’t wait that long for an image. For example, in my experience Facebook is pretty patient, but Elon Musk’s personal mystique-puncturing machine will give up fast. If I wanted to share something over there, I usually had to keep hitting their card validator a bunch until it worked once. Well, back when the card validator worked at all, which it currently does not.
In part to address these issues, a few months ago Vercel came out with a new library for generating on-demand images. The underlying library Satori turns HTML and a subset of CSS into an SVG, and then another library resvg-js turns that SVG into a .png image. That sounds like a circuitous way to go about it, but because it doesn’t involve a headless browser it’s still quite quick—many times faster than taking a screenshot with puppeteer
.
Geoff Rich caught my interest in all of this with a terrific article detailing how to get Satori working with SvelteKit. I build this site with SvelteKit and I suddenly have some free time, so I had to give it a go.
I was able to follow Geoff’s excellent instructions and I had something working locally with SvelteKit pretty quickly. It’s awesome! You can write your template as a Svelte component, you can pre-load web fonts so they’re always there and you have consistent results in any operating system, and it’s super fast.
However (man, nothing comes easy), I hit some snags:
- My initial plan was to continue to use a Netlify function to build these on-demand for any URL, but due to some esbuild nonsense it doesn’t look like you can use
resvg-js
in Netlify functions. I think you could maybe use esbuild in advance to pre-bake the library and include that with the function code, but there are some operating-system-specific files that make that tricky. At least I couldn’t figure it out. Because I use SvelteKit’s static adapter to pre-build my whole site in advance, I shifted to trying to build these images as part of the site’s build process. - Vite, the dev tooling behind SvelteKit, would sometimes throw a wrench into things with differences between development and production. For example, I would import my logo image like
import Logo from '$lib/assets/logo.png'
, and it’d be a URL path in development (/src/lib/assets/logo.png
) and Satori could fetch that image just fine, but in production the logo file would be small enough that Vite would convert it to a base64 string as part of its static asset handling. - Speaking of fetching images, Satori uses the global
fetch()
when rendering images (you can provide a full URL to animg
tag), but SvelteKit’sadapter-static
throws an error if anything uses the built-infetch
method while pre-rendering the site; you’re supposed to use SvelteKit’s providedfetch
implementation.
Why is everything hard??? (╯°□°)╯︵ ┻━┻
After stepping away for a bit, I came up with something that works with adapter-static
and lets you create these images as part of the SvelteKit build process.
Adding dedicated metadata routes
One peculiarity of my setup is that some pages of my site exist as SvelteKit routes, but other bits like articles are dynamically generated from a big folder of Markdown files with the text content and metadata. So I need a consistent way to get page metadata (eg title, description, maybe an image).
For the pages where I want to have a generated fancy image, I added dedicated data routes.
I wanted a consistent URL pattern so the server route that actually generates the image doesn’t have to have different logic for different routes. I settled on tacking /og-image-data.json
to the end of whatever URL I actually cared about.
So for the article pages that live at /blog/[slug]
, I added a server route:
// src/routes/blog/[slug]/og-image-data.json/+server.js
import { json, error } from '@sveltejs/kit'
import { getContentForPathname } from '$lib/data'
export const prerender = true
/** @type {import('./$types').RequestHandler} */
export const GET = async ({ request, params, url }) => {
const pageData = await getContentForPathname(`/blog/${params.slug}`)
if (!pageData) {
throw error(404, `Post not found for ${url.pathname}`)
}
const { title, description, hero, heroHeight, heroWidth } = pageData
return json({ title, description, hero, heroHeight, heroWidth })
}
Nothing fancy: if something requests /blog/my-slug/og-image-metadata.json
, I look up that my-slug
article and return some of its information like the title and path to the primary “hero” image. The particulars of getContentForPathname
don’t matter much; in my case I’m scanning a folder of markdown files and finding one that matches params.slug
.
For site pages that are just SvelteKit routes, I just return some hard-coded data. Like here’s the metadata route for the home page:
// src/routes/og-image-data.json/+server.js
import { json } from '@sveltejs/kit'
export const prerender = true
/** @type {import('./$types').RequestHandler} */
export const GET = async ({ request, params, url }) => {
return json({
title: 'Lee Reamsnyder',
description: 'Professional DIVeloper',
})
}
So now we’re all set for nabbing data that we’re going to feed into a template later.
Adding the meta tags
I have a dedicated component for social media metadata. Here’s the pertinent bits for possibly adding the <meta property="og:image" />
tag:
<script>
import { page } from '$app/stores'
export let generateOgImage = false
let socialImageData
let socialImagePath
let socialImageUrl
$: {
socialImageData = `${$page.url.pathname === '/' ? '' : $page.url.pathname}/og-image-data.json`
socialImagePath = `/og-image-generator${
$page.url.pathname === '/' ? '' : $page.url.pathname
}/og-image.jpg`
socialImageUrl = `https://www.leereamsnyder.com${socialImagePath}`
}
</script>
<svelte:head>
{#if generateOgImage}
<meta property="og:image" content={socialImageUrl} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<link rel="og-image-data-for-prerender" href={socialImageData} />
<link rel="og-image-for-prerender" href={socialImagePath} />
{/if}
</svelte:head>
You need a full URL for the og:image
; my domain is hard-coded here because only the live site URL matters for these in the end.
Note the non-standard bits:
<link rel="og-image-data-for-prerender" href={socialImageData} />
<link rel="og-image-for-prerender" href={socialImagePath} />
Those are needed so that SvelteKit’s adapter-static
will see the paths for page metadata and the image when it parses the markup during the build process. If you don’t link to a dynamic route somehow, the build process will either not generate anything or outright fail. Although this is a little annoying, it’s not the end of the world to have some extra HTML.
When everything is calculated, you get markup that looks like this in the page <head>
:
<meta
property="og:image"
content="https://www.leereamsnyder.com/og-image-generator/blog/dynamic-social-media-images-with-sveltekit-and-resvg-js/og-image.jpg"
/>
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<link
rel="og-image-data-for-prerender"
href="/blog/dynamic-social-media-images-with-sveltekit-and-resvg-js/og-image-data.json"
/>
<link
rel="og-image-for-prerender"
href="/og-image-generator/blog/dynamic-social-media-images-with-sveltekit-and-resvg-js/og-image.jpg"
/>
The generateOgImage
property lets me opt in to generating the images on a route-by-route basis when I include this component. For example, here’s what it looks like when I’m building an article:
<script>
// src/routes/blog/[slug]/+page.svelte
import SocialMediaMeta from '$lib/components/SocialMediaMeta.svelte'
/** @type {import('./$types').PageData} */
export let data
$: ({ post } = data)
$: generateOgImage = post.favorite || new Date().getFullYear() - post.date.getFullYear() < 5
</script>
<SocialMediaMeta {generateOgImage} />
One tradeoff that I’ve accepted is that I’ve stopped making potential images for every single page on the site. I don’t care if someone shares, like, a page for a random month from the archives and that doesn’t have a custom image.
I frankly only need them for recent articles, evergreen articles, and maybe the home page. I am not a prolific publisher, so this ends up spitting out about 40 images, which takes only a few seconds during the build process. If I wanted to build more, this appears to be fast enough to generate maybe a few hundred images relatively quickly.
The image template Svelte component
Next up, we need a Svelte component that will output the HTML we’ll eventually feed into Satori.
I co-locate this OgCard.svelte
component with the server route that we’ll get to next.
I’ll spare you the full layout, but mine starts out like this:
<script>
// src/routes/og-image-generator/[...path]/og-image.jpg/OgCard.svelte
export let logo
export let title
export let description
export let hero
export let heroWidth
export let heroHeight
</script>
<div
style:display="flex"
style:flex-direction="column"
style:height="100%"
style:width="100%"
style:background="#fff"
style:color="#333"
>
<div
style:display="flex"
style:height="8px"
style:width="100%"
style:background-image={'linear-gradient(to right, #931077, #37b5c3 50%, #bbe354)'}
/>
<div style:display="flex" style:align-items="center" style:padding="1.6rem" style:flex-grow="1">
<!-- and so on -->
</div>
<div
style:display="flex"
style:height="8px"
style:width="100%"
style:background-image={'linear-gradient(to left, #931077, #37b5c3 50%, #bbe354)'}
/>
</div>
This is a little gross because you have to use flexbox for everything and put inline styles everywhere (this is all a limitation from Satori), but it’s not awful. Svelte itself has a shorthand syntax for inline styles that makes it a little more tolerable.
The route to make the actual image
At long last, where everything comes together.
Here’s my server route that generates the images in its entirety:
// src/routes/og-image-generator/[...path]/og-image.jpg/+server.js
import { Resvg } from '@resvg/resvg-js'
import { error } from '@sveltejs/kit'
import { readFileSync } from 'fs'
import satori from 'satori'
import { html as toReactNode } from 'satori-html'
import sharp from 'sharp'
import OgCard from './OgCard.svelte'
export const prerender = true
const height = 630
const width = 1200
const logo = readFileSync(`${process.cwd()}/src/lib/assets/plane-logo@1x.png`)
const newKansasBlack = readFileSync(
`${process.cwd()}/src/lib/assets/fonts/kansasnew-black-webfont.woff`
)
const concourseRegular = readFileSync(
`${process.cwd()}/src/lib/assets/fonts/concourse_t4_regular.woff`
)
const concourseSmallCaps = readFileSync(
`${process.cwd()}/src/lib/assets/fonts/concourse_c4_regular.woff`
)
/** @type {import('./$types').RequestHandler} */
export const GET = async ({ request, params, fetch }) => {
const { path } = params
const dataPath = `${path === '' ? '' : '/'}${path}/og-image-data.json`
const response = await fetch(dataPath, {
headers: {
Accept: 'application/json',
},
})
if (!response.ok) {
throw error(response.status, `Error retrieving page data at ${dataPath}`)
}
const data = await response.json()
const { title, description, hero, heroHeight, heroWidth } = data
let heroBase64
if (hero) {
const heroJpg = await sharp(`${process.cwd()}/static${hero}`)
.resize(null, 550, { withoutEnlargement: true })
.jpeg({ quality: 40 })
.toBuffer()
heroBase64 = `data:image/jpg;base64,${heroJpg.toString('base64')}`
}
const result = OgCard.render({
title,
description,
heroHeight,
heroWidth,
hero: heroBase64,
logo: `data:image/png;base64,${logo.toString('base64')}`,
})
const element = toReactNode(result.html)
const svg = await satori(element, {
fonts: [
{
name: 'ConcourseCaps',
weight: 400,
style: 'normal',
data: concourseSmallCaps,
},
{
name: 'Concourse',
weight: 400,
style: 'normal',
data: concourseRegular,
},
{
name: 'NewKansas',
weight: 900,
style: 'normal',
data: newKansasBlack,
},
],
height,
width,
})
const resvg = new Resvg(svg, {
fitTo: {
mode: 'width',
value: width,
},
})
const png = resvg.render().asPng()
const jpg = sharp(png).jpeg({ quality: 70, progressive: true })
return new Response(jpg, {
headers: {
'content-type': 'image/jpg',
},
})
}
We’ll go through that bit by bit.
First, on startup we’re using fs.readFileSync()
to load my logo image and fonts as Buffer
s that we’ll use later:
const logo = readFileSync(`${process.cwd()}/src/lib/assets/plane-logo@1x.png`)
const newKansasBlack = readFileSync(
`${process.cwd()}/src/lib/assets/fonts/kansasnew-black-webfont.woff`
)
const concourseRegular = readFileSync(
`${process.cwd()}/src/lib/assets/fonts/concourse_t4_regular.woff`
)
const concourseSmallCaps = readFileSync(
`${process.cwd()}/src/lib/assets/fonts/concourse_c4_regular.woff`
)
In the exported GET
method, we’re pulling off the path
from params
. I’m using SvelteKit rest parameters to capture multiple potential [path]
segments.
export const GET = async ({ request, params, fetch }) => {
const { path } = params
// snip
}
For the home page, which would have an og:image property of /og-image-generator/og-image.jpg
, path
is an empty string; an article would have /og-image-generator/blog/my-article-slug/og-image.jpg
and path
would be blog/my-article-slug
.
With that, we fetch the metadata for that path at ${path}/og-image-data.json
:
const dataPath = `${path === '' ? '' : '/'}${path}/og-image-data.json`
const response = await fetch(dataPath, {
headers: {
Accept: 'application/json',
},
})
if (!response.ok) {
throw error(response.status, `Error retrieving page data at ${dataPath}`)
}
const data = await response.json()
const { title, description, hero, heroHeight, heroWidth } = data
If you weren’t using adapter-static
, you could do something like pass along metadata in query parameters like /og-image-generator?title=Something&description=A+description
, but you can’t do that with a fully static site and I wanted my URLs to look like images regardless.
Next, if an article has a “hero” image, I use sharp
, an image manipulation library, to open that image file from disk, shrink it, and then turn the resulting Buffer
into a base64-encoded string:
let heroBase64
if (hero) {
const heroJpg = await sharp(`${process.cwd()}/static${hero}`)
.resize(null, 550, { withoutEnlargement: true })
.jpeg({ quality: 40 })
.toBuffer()
heroBase64 = `data:image/jpg;base64,${heroJpg.toString('base64')}`
}
We’re converting the image to a base64 string to avoid Satori using fetch
to load the image from a URL. That works in development, but as I mentioned above, if anything uses the global fetch
during SvelteKit prerendering, it breaks.
In my OgCard
component, I use that heroBase64
string as a src
attribute for an <img />
and it just works. Hat tip to the sharp
folks for enlightening me on how to turn any Buffer data into a base64 string.
With that done, we can use Svelte’s server-side rendering to turn the OgCard
component with passed-in properties into an HTML string:
const result = OgCard.render({
title,
description,
heroHeight,
heroWidth,
hero: heroBase64,
logo: `data:image/png;base64,${logo.toString('base64')}`,
})
Note that I’m doing a similar conversion of my logo
image data to a base64 string.
Next, satori
doesn’t work with regular HTML; it needs a React
tree instead. The satori-html
library has a utility to do just that:
import { html as toReactNode } from 'satori-html'
// snip
const element = toReactNode(result.html)
Now we can pass that React tree into Satori to generate SVG data:
const svg = await satori(element, {
fonts: [
{
name: 'ConcourseCaps',
weight: 400,
style: 'normal',
data: concourseSmallCaps,
},
{
name: 'Concourse',
weight: 400,
style: 'normal',
data: concourseRegular,
},
{
name: 'NewKansas',
weight: 900,
style: 'normal',
data: newKansasBlack,
},
],
height,
width,
})
You have to provide Satori with some font data and descriptors (weight, style, etc) for it to be able to render text at all. I’m passing in the Buffer
s for each font file that I readFileSync
'd up top.
Now that we have SVG data, we can pass that into resvg-js
and generate .png file data:
const resvg = new Resvg(svg, {
fitTo: {
mode: 'width',
value: width,
},
})
const png = resvg.render().asPng()
Next, those .png files could be almost 500kb or so, but a .jpg version was a more palatable 70kb. I was already using sharp
, so that conversion is trivial:
const jpg = sharp(png).jpeg({ quality: 70, progressive: true })
Then, finally, we return that JPEG image data in the response:
return new Response(jpg, {
headers: {
'content-type': 'image/jpg',
},
})
Phew, that’s it!
Here’s what that ends up looking like for, say, my big Hades build guide that lives at /blog/hades-build-guide
and has an og-image at /og-image-generator/blog/hades-build-guide/og-image.jpg
:
If everything is working properly, 👆 is the actual og:image for that post.
🎉🎉🎉
So it took some work to get there, but these images are easier for me to develop and tweak, render consistently, and get generated fast enough to build them in advance. Not bad!