ionicons-v5-a Back
Generating static Open Graph (OG) images in Astro using @vercel/og
astro

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.

Example of generated og image
Example of generated og image

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.


Dzmitry Kozhukh

Written by Dzmitry Kozhukh

Frontend developer


Next