Dynamic OG Images with Vercel

3 min read
Open Graph image generation concept showing a social media card with dynamic content

Every blog post needs a preview image for social sharing. Twitter, LinkedIn, Slack - they all pull the og:image meta tag and display it when someone shares a link. Creating these manually for each post is tedious. I wanted them generated automatically.

Vercel’s @vercel/og package solves this. It generates images on-demand using a JSX-like syntax. The image is created once, cached forever, and served from the edge.

One Route Handles Everything

Instead of creating separate endpoints for each page, I used Astro’s catch-all route pattern:

src/pages/og/[...route].ts

This single file handles requests like /og/, /og/about, /og/blog/my-post-slug. The route parameter tells me which page needs an image.

For static pages (home, about, blog index), I use predefined metadata. For blog posts, I read from Astro’s content collection:

if (route?.startsWith("blog/")) {
    const slug = route.replace("blog/", "");
    const posts = await getCollection("blog");
    const post = posts.find((p) => p.id === slug);

    pageData = {
        title: post.data.title,
        description: post.data.description,
    };
}

The meta tag in my base layout points to this endpoint:

<meta
    property="og:image"
    content={new URL(`og${Astro.url.pathname}`, Astro.site)}
/>

When someone shares /blog/my-post, Twitter fetches /og/blog/my-post, which generates an image with that post’s title and description.

The Gradient Problem

I wanted a gradient background. But @vercel/og doesn’t support CSS gradients. No linear-gradient(), no radial-gradient().

The workaround: absolutely positioned divs with CSS blur filters. Four of them, overlapping, each a different color:

{
    type: "div",
    props: {
        style: {
            position: "absolute",
            width: "250px",
            height: "500px",
            borderRadius: "999px",
            opacity: 0.4,
            filter: "blur(100px)",
            right: 0,
            top: -50,
            backgroundColor: "#C33764",
        },
    },
},

I stacked four of these with different positions, sizes, and colors: #C33764, #1D2671, #516395, #614385. The blur filter smears them together into something that looks like a gradient. Not elegant, but it works.

Fonts Need Special Handling

Custom fonts require WOFF format - not WOFF2. They also need to be loaded from the filesystem, not fetched from a URL. I keep them in an assets folder:

const [playfairFont, robotoFont] = await Promise.all([
    fs.promises.readFile(path.join(assetsPath, "PlayfairDisplay-Regular.woff")),
    fs.promises.readFile(path.join(assetsPath, "Roboto-Regular.woff")),
]);

Then pass them to the ImageResponse:

new ImageResponse(html, {
    width: 1200,
    height: 630,
    fonts: [
        { name: "Playfair Display", data: playfairFont, style: "normal" },
        { name: "Roboto", data: robotoFont, style: "normal" },
    ],
});

Optimizing the Profile Image

Each OG image includes my profile photo. Loading the full image every time wastes bandwidth. I use sharp to resize it once and convert to grayscale:

const optimizedImage = await sharp(imageBuffer)
    .resize(90, 90, { fit: "cover", position: "center" })
    .grayscale()
    .toBuffer();

const imageBase64 = `data:image/png;base64,${optimizedImage.toString("base64")}`;

The base64 string embeds directly into the image template. No external requests.

The Vercel Gotcha

This worked locally but failed on Vercel. The fonts and images weren’t being bundled into the serverless function.

The fix: explicitly tell Vercel to include these files in astro.config.mjs:

adapter: vercel({
    includeFiles: [
        "assets/PlayfairDisplay-Regular.woff",
        "assets/Roboto-Regular.woff",
        "assets/profileImage.png",
    ],
}),

Without this, the function tries to read files that don’t exist.

Caching

OG images don’t change once generated. The title and description are baked in. So I cache them aggressively:

headers.set("Cache-Control", "public, max-age=31536000, immutable");

That’s one year. After the first request, Vercel’s CDN serves the cached image. The function only runs once per unique URL.

The Result

Every page on this site now has a unique OG image generated on the fly. The first share might take a second to generate, but after that it’s instant. No manual image creation, no Figma templates, no forgetting to update the preview when I change a post title.

The images match the site’s design because they use the same fonts and colors. And since they’re generated from the actual content, they’re always accurate.

Go back to the previous page
Share: