10 min read
Generating static Open Graph (OG) images in Astro using @vercel/og
I prefer my publication to be as automated as possible, allowing me to focus on content creation. Let's explore how to automate og:image generation in Astro using @vercel/og.
Step 1: Install @vercel/og
Let’s install @vercel/og package:
npm i @vercel/og
Step 2: Create an Astro endpoint
To create a custom endpoint, add a .js or .ts file to the /pages directory. The .js or .ts extension will be removed during the build process, so the name of the file should include the extension of the data you want to create.
You might need a different types of og images for different pages. So let’s create an og:image endpoint for our blog posts.
// src/pages/blog/[slug]/og.png.ts
import { getCollection, type CollectionEntry } from 'astro:content';
import fs from 'fs';
import path from 'path';
import { ImageResponse } from '@vercel/og';
interface Props {
params: { slug: string };
props: { post: CollectionEntry<'blog'> };
}
export async function GET({ props }: Props) {
const { post } = props;
// using custom font files
const DmSansBold = fs.readFileSync(path.resolve('./fonts/DMSans-Bold.ttf'));
const DmSansReqular = fs.readFileSync(
path.resolve('./fonts/DMSans-Regular.ttf'),
);
// post cover with Image is pretty tricky for dev and build phase
const postCover = fs.readFileSync(
process.env.NODE_ENV === 'development'
? path.resolve(
post.data.cover.src.replace(/\?.*/, '').replace('/@fs', ''),
)
: path.resolve(post.data.cover.src.replace('/', 'dist/')),
);
// Astro doesn't support tsx endpoints so usign React-element objects
const html = {
type: 'div',
props: {
children: [
{
type: 'div',
props: {
// using tailwind
tw: 'w-[200px] h-[200px] flex rounded-3xl overflow-hidden',
children: [
{
type: 'img',
props: {
src: postCover.buffer,
},
},
],
},
},
{
type: 'div',
props: {
tw: 'pl-10 shrink flex',
children: [
{
type: 'div',
props: {
style: {
fontSize: '48px',
fontFamily: 'DM Sans Bold',
},
children: post.data.title,
},
},
],
},
},
{
type: 'div',
props: {
tw: 'absolute right-[40px] bottom-[40px] flex items-center',
children: [
{
type: 'div',
props: {
tw: 'text-blue-600 text-3xl',
style: {
fontFamily: 'DM Sans Bold',
},
children: 'Dzmitry Kozhukh',
},
},
{
type: 'div',
props: {
tw: 'px-2 text-3xl',
style: {
fontSize: '30px',
},
children: '|',
},
},
{
type: 'div',
props: {
tw: 'text-3xl',
children: 'Blog',
},
},
],
},
},
],
tw: 'w-full h-full flex items-center justify-center relative px-22',
style: {
background: '#f7f8e8',
fontFamily: 'DM Sans Regular',
},
},
};
return new ImageResponse(html, {
width: 1200,
height: 600,
fonts: [
{
name: 'DM Sans Bold',
data: DmSansBold.buffer,
style: 'normal',
},
{
name: 'DM Sans Regular',
data: DmSansReqular.buffer,
style: 'normal',
},
],
});
}
// to generate an image for each blog posts in a collection
export async function getStaticPaths() {
const blogPosts = await getCollection('blog');
return blogPosts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
Step 3: Checking the result
You can check the result by adding /og.png
to this blog post url.
Step 4: Adding OG tags in HTML
I use my custom component Metadata.astro
to insert tags to head:
---
interface Props {
title: string;
description: string;
image: string;
canonicalUrl: string;
type: 'website' | 'article';
publishedTime?: string;
}
const { title, description, image, canonicalUrl, type, publishedTime } =
Astro.props;
---
<title>{title}</title>
<meta name="description" content={description} />
{
publishedTime && (
<meta property="article:published_time" content={publishedTime} />
)
}
<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} />
Using it in the blog post template:
<Metadata
slot="head"
title={post.data.title}
description={post.data.excerpt}
image={`${import.meta.env.SITE}/blog/${post.slug}/og.png`}
canonicalUrl={`${import.meta.env.SITE}/blog/${post.slug}/`}
publishedTime={post.data.date.toISOString()}
type="article"
/>
Wrapping up
Leveraging @vercel/og
enabled me to swiftly transition from Next.js and generate distinct og images for various pages.
Cons: can’t write markup in jsx.