Feature blog image

Social Media Cards with @vercel/og

10 min read

Recently I added social media cards to this blog. Social media cards are images which are displayed, when you or someone else post a link to your content on a social media platform. Posts with a nice looking image are more often clicked, as those without an image. Lets see how we can implement them.

Possible implementations

I've implemented social media cards many times before and I've used many different techniques to implement them, but all of them had their downsides.

Manually creation

The simplest way is to create the social media cards manually. Just open figma or a similar tool and create a card for each post. This approach works, but it is very error prone and time consuming. We had to remember to create a new card, every time we create a new post.

Node Canvas

Another approach is it to create a background image and insert the texts and the metadata with a canvas library like node-canvas. This approach is very well described in this Learn With Jason episode. But with a canvas library we have to specify on which coordinates we want to render our texts and it does not wrap automatically if it becomes to long.

External service

We could also use an external service like cloudinary. The post of Jason describes this approach very detailed. Cloudinary is able to break texts on a certain width, but the api is not very intuitive and the largest part of the card is an image which must be build with an external tool.

SVG and opentype.js

With an SVG we can define our image as code, which is pretty neat. But SVG is not able to wrap texts. We can use opentype.js to calculate the width of our texts and to wrap it manually. Finally we have to convert the SVG to a PNG, because the most of social media platforms do not support SVG. This approach could work, but requires a lot of code and it is very complicated.

Headless browser

If all these solutions have problems with wrapping texts or require us to build the image with an external tool, why could we just use HTML+CSS to define our image? This is possible! We can create our card with HTML+SVG+CSS, open a browser and create a screenshot of the result. I've used this approach in the past and it worked well. The problem is that the process is very slow. This example creates the cards during the build. Creating the cards during the build requires external caching, because it slows down the build extremely. Also generating cards with this approach in a serverless function is often not possible, because it requires a browser within the function which breaks the size limit of those functions.

@vercel/og

@vercel/og promises a new way of generating social media cards, which does not have the downsides of the other approaches. The library uses Satori under the hood. Satori describes itself as follows:

Enlightened library to convert HTML and CSS to SVG.

We can use HTML+SVG+CSS to create an image without the requirement to start a browser for a screenshot. This sounds exactly like the solution I've been looking for. So let's try it out.

Hello World

First we have to install the package from the npm registry:

pnpm
yarn
npm
Copy

pnpm add @vercel/og

After the installation we have to create an edge function, which renders our social media card.

pages/api/og.tsx
Copy

import { ImageResponse } from "@vercel/og";
export const config = {
runtime: "experimental-edge",
};
export default function () {
return new ImageResponse(
(
<div
style={{
fontSize: 128,
background: "white",
width: "100%",
height: "100%",
display: "flex",
textAlign: "center",
alignItems: "center",
justifyContent: "center",
}}
>
Hello world!
</div>
),
{
width: 1200,
height: 600,
}
);
}

This was easy, so lets try to create something more complicated.

The goal

I've headed over to figma and started to create an example social media card. I've ended with the following:

Figma template

Ok, now lets see if we can implement this with @vercel/og.

The implementation

The first hurdle that I had to overcome was the usage of custom fonts. The figma template uses Raleway and Cabin. These are the same fonts which are used on the website.

Fonts

Unfortunately we can't use the @next/font package with Satori. So we have to copy the font files to our project.

warning

It looks like Satori has problems with dynamic fonts. Therefore only static fonts should be used and we have to load each weight used separately.

In an edge function we can't access the filesystem, but we can use fetch to load the fonts:

pages/api/og/[slug].tsx
Copy

const ralewayBold = fetch(new URL(`../../../../content/fonts/Raleway-Bold.ttf`, import.meta.url)).then((res) =>
res.arrayBuffer()
);
const cabinSemiBold = fetch(new URL(`../../../../content/fonts/Cabin-SemiBold.ttf`, import.meta.url)).then((res) =>
res.arrayBuffer()
);
const cabinMedium = fetch(new URL(`../../../../content/fonts/Cabin-Medium.ttf`, import.meta.url)).then((res) =>
res.arrayBuffer()
);

Than we have to configure our fonts in the ImageResponse:

pages/api/og/[slug].tsx
Copy

return new ImageResponse(<div>...</div>, {
width: 1200,
height: 630,
fonts: [
{
name: "Raleway",
data: await ralewayBold,
style: "normal",
weight: 700,
},
{
name: "Cabin",
data: await cabinMedium,
style: "normal",
weight: 400,
},
{
name: "Cabin",
data: await cabinSemiBold,
style: "normal",
weight: 600,
},
],
});

Now we are able to use the fonts in our jsx:


<div style={{ fontFamily: '"Cabin"' }}>Hello from Cabin</div>

warning

Pay attention to the additional double quotes around the font name. If we don't wrap the font in double quotes, @vercel/og will not use the font.

Tailwind

@vercel/og offers experimental Tailwind support and I love Tailwind so I decided to use it.

In order to use Tailwind utilities, we can use the tw attribute on any element:


<div tw="rounded-xl border-2 border-zinc-700 shadow-lg"></div>

The first problem I've noticed is that my Tailwind config was not used. It looks like @vercel/og uses the default config and ignores the configuration of the project.

The second problem I've noticed is that not all utilities seem to work. I'm not sure what the reason for that is, but some utilities work and others not. For example the gradient utilities are not working, although those CSS properties are supported by Satori. Maybe utilities that use CSS variables are not supported? However, the following utilities I want to use are not working as expected:

Another problem took me some time. In a normal Tailwind project, the Tailwind preflight is loaded, which removes the default browser margins and paddings from each element. This does not happen for @vercel/og and it took me some time to figure out where the spacings were coming from. I've fixed it by just using div and span tags. Semantics of elements could be ignored here, because in the end we will create an image and all the elements will be gone anyways.

By the way, the debug option on the ImageResponse is pretty handy. If it is used, every element gets a colored border, which helps to determine which element takes how much space.

Ok, enough about problems and workarounds, here is the code:

pages/api/og/[slug].tsx
Copy

import { ImageResponse } from "@vercel/og";
import { allPosts } from "contentlayer/generated";
import { NextRequest } from "next/server";
export const config = {
runtime: "experimental-edge",
};
const ralewayBold = fetch(new URL(`../../../../content/fonts/Raleway-Bold.ttf`, import.meta.url)).then((res) =>
res.arrayBuffer()
);
const cabinSemiBold = fetch(new URL(`../../../../content/fonts/Cabin-SemiBold.ttf`, import.meta.url)).then((res) =>
res.arrayBuffer()
);
const cabinMedium = fetch(new URL(`../../../../content/fonts/Cabin-Medium.ttf`, import.meta.url)).then((res) =>
res.arrayBuffer()
);
const Image = async (req: NextRequest) => {
const slug = req.nextUrl.pathname.replace("/api/og/posts/", "");
const post = allPosts.find((p) => slug === p._raw.flattenedPath);
if (!post) {
return new Response(
JSON.stringify({
message: "Could not find post with slug: " + slug,
}),
{
status: 404,
}
);
}
return new ImageResponse(
(
<div
tw="w-full h-full p-4 flex"
style={{
backgroundImage: "linear-gradient(to right, #0891B2, #164E63)",
fontFamily: '"Cabin"',
}}
>
<div tw="rounded-xl border-2 border-zinc-700 w-full h-full p-4 flex bg-zinc-800 shadow-lg">
<img
width="375"
height="562"
src={post.image}
tw="rounded-xl border-2 border-zinc-700"
style={{ objectFit: "cover" }}
/>
<div tw="flex flex-col px-6 w-[740px] h-full justify-between">
<div tw="flex flex-col">
<span
tw="text-7xl font-bold text-zinc-50 mb-6"
style={{
fontFamily: '"Raleway"',
}}
>
{post.title}
</span>
<span tw="text-5xl text-zinc-300">{post.summary}</span>
</div>
<div tw="flex justify-between items-end w-full text-zinc-400 text-xl">
<span>{post.readingTime}</span>
<div
tw="flex text-4xl font-bold"
style={{
fontFamily: '"Raleway"',
}}
>
<span tw="text-zinc-50">sdorra</span>
<span tw="text-cyan-400">.dev</span>
</div>
<span>{post.date.substring(0, post.date.indexOf("T"))}</span>
</div>
</div>
<div
tw="w-32 h-32 border-t-2 border-t-zinc-700 border-l-2 border-l-zinc-700 bg-zinc-800 rounded-full absolute left-[324px] bottom-[59px]"
style={{
transform: "rotate(-45deg)",
}}
/>
<div tw="absolute left-[338px] bottom-[68px] flex items-center">
<img
tw="rounded-full border-2 border-zinc-700"
width="110"
height="110"
src="https://avatars.githubusercontent.com/u/493333"
/>
<p tw="ml-6 text-4xl font-semibold text-zinc-400">Sebastian Sdorra</p>
</div>
</div>
</div>
),
{
width: 1200,
height: 630,
debug: false,
fonts: [
{
name: "Raleway",
data: await ralewayBold,
style: "normal",
weight: 700,
},
{
name: "Cabin",
data: await cabinMedium,
style: "normal",
weight: 400,
},
{
name: "Cabin",
data: await cabinSemiBold,
style: "normal",
weight: 600,
},
],
}
);
};
export default Image;

With about 120 lines of code I was able to create the following image:

Result

Now it is time to deploy it to vercel.

Edge function size limit

The first deployment to vercel ends with the following error:

error

Provided Edge Function is too large

It took me some time to figure out what is going on. I've created a separate post about this problem together with my workaround.

Unsplash image size

After I worked my way around the size limitation of the edge function, the deployment was successful but the og endpoint responded with an error 500. The error in the vercel log did not help at all. So I started to look at the code and began to comment out parts of the code. Fortunately it didn't take very long to find out that the image of the post was the problem. It looked like there is another size limitation. The image was loaded from Unsplash and about 7mb in size. Luckily Unsplash has an api to resize the image, so we can fetch the image in exactly the size we need.

pages/api/og/[slug].tsx
Copy

const createImageUrl = (src: string, width: number, height: number) => {
if (src.startsWith("https://images.unsplash.com/") && !src.includes("?")) {
return `${src}?fit=crop&w=${width}&h=${height}`;
}
return src;
};
<img
width="375"
height="562"
src={createImageUrl(post.image, 375, 562)}
tw="rounded-xl border-2 border-zinc-700"
style={{ objectFit: "cover" }}
/>;

After this fix we are able to generate social media cards on our production site.

Meta tags

Now it is time to link our social media cards in the header of our post page, so that the social media networks are able to find the image. Therefore we are creating a head.tsx file with the following content:

app/posts/[slug]/head.tsx
Copy

import { allPosts } from "contentlayer/generated";
type Props = {
params: {
slug: string;
};
};
const fqdn = process.env.NEXT_PUBLIC_FQDN ? process.env.NEXT_PUBLIC_FQDN : "sdorra.dev";
const Head = ({ params }: Props) => {
const post = allPosts.find((p) => p._raw.flattenedPath === params.slug);
if (!post) {
return null;
}
return (
<>
<title>{post.title}</title>
<meta name="description" content={post.summary} />
<meta property="og:url" content={`https://${fqdn}/posts/${post._raw.flattenedPath}`} />
<meta property="og:type" content="article" />
<meta name="og:title" content={post.title} />
<meta name="og:description" content={post.summary} />
<meta property="og:image" content={`https://${fqdn}/api/og/posts/${post._raw.flattenedPath}`} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:domain" content={fqdn} />
<meta property="twitter:url" content={`https://${fqdn}/posts/${post._raw.flattenedPath}`} />
<meta name="twitter:title" content={post.title} />
<meta name="twitter:description" content={post.summary} />
<meta property="twitter:image" content={`https://${fqdn}/api/og/posts/${post._raw.flattenedPath}`} />
</>
);
};
export default Head;

We use og tags which are accepted by most of the social media sites. Except Twitter where I've had strange results if I only used og tags. So I've added the Twitter tags besides the og tags.

warning

Ensure that the image url is a complete url with scheme and hostname. The most social media sites do not render images with a relative url.

Now we can test the results with socialsharepreview.

Conclusion

Even if there are a few stumbling blocks with @vercel/og, it is the best solution for social media cards I've worked with so far.

Posted in: vercel, edge, ogp