Back to blog
Generate Dynamic SEO Images with Vercel OG (Satori) for Tanstack Start, Astro & Laravel

Generate Dynamic SEO Images with Vercel OG (Satori) for Tanstack Start, Astro & Laravel

Want a quick summary of this post? Tune in 🎧
Table of Contents

1. Introduction 🚀

Not too long ago, we created static SEO images for our websites (and that’s still valid!). If you’re in the SEO game and it matters to you, having an og:image is essential - especially when sharing your website on social media platforms, Discord, Slack, and other platforms.

The og:image should be the “main” image of the page or what you want to highlight, usually related to the page content. If none is provided, search engines like Google will take the first image found on the page and use it as the preview for SERP results.

But you’re probably here for a reason 😌 - to generate dynamic SEO images or on-demand images, right?

GitHub was one of the first to implement this, generating dynamic SEO images on-demand that show current repository stats like forks, stars, and issues, plus Pull Request information and text in real-time. This was amazing because sharing these on Slack or Discord would instantly show what the current Pull Request is about, giving you a clear view in seconds!

Thankfully, @shuding 🐐 created Satori / @vercel/og, a library to generate dynamic SEO images using HTML, JSX, CSS, and even Tailwind CSS support.

In this article, we’ll explore how to implement this in TanStack Start and Astro, but the same principles can be applied to any JavaScript framework.

Let’s jump into the code:

2. Creating an Image Generator 🎨

Since this can run in any framework, we’ll abstract our logic away from the framework and create a generic image generator. In this example, I’ll showcase what I’ve used for my Astro blog, but you can tweak it to your own needs and add more information.

import type { CollectionEntry, CollectionKey } from "astro:content";
import { SITE } from "@consts";
import { ImageResponse } from '@vercel/og';
import fs from 'node:fs'
import path from 'node:path'
import { encode } from "blurhash";
import { getPixels } from "@unpic/pixels";
import { blurhashToCssGradientString } from "@unpic/placeholder";

/**
 * Loads a font from Google Fonts
 * @param font - The font name
 * @param text - The text to load the font for
 * @param weight - The font weight to load
 * @returns The font data
 */
async function loadFont(font: string, text: string, weight: number) {
  const url = `https://fonts.googleapis.com/css2?family=${font}:wght@${weight}&text=${encodeURIComponent(text)}`
  const css = await (await fetch(url)).text()
  const resource = css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/)
  if (resource) {
    const response = await fetch(resource[1])
    if (response.status == 200) {
      return await response.arrayBuffer()
    }
  }
  throw new Error('failed to load font data')
}


/**
 * Generates a SEO image for a blog post or project
 * @param title - The title of the blog post or project
 * @param text - The text to display in the image
 * @param description - The description of the blog post or project
 * @param tags - The tags of the blog post or project
 * @param image - The image to display in the image
 * @returns The SEO image
 */
export interface SeoImageGeneratorProps {
  title: string;
  text: string
  image?: {
    src: string
    width: number
    height: number
    format: 'png' | 'jpg' | 'jpeg' | 'webp' | 'tiff' | 'gif' | 'svg' | 'avif'
  };
}

export async function generateSeoImage({
  title,
  image,
  text
}: SeoImageGeneratorProps) {

  const assetsPrefix = process.env.NODE_ENV === 'development' ? './public' : './dist'
  const siteImage = fs.readFileSync(path.resolve(assetsPrefix, 'static/avatar.png'))

  const siteImageElement = {
    type: 'img',
    props: {
      tw: 'w-6 h-6 rounded-full mr-2',
      src: siteImage.buffer,
    },
  };

  const siteTitleElement = {
    type: 'div',
    props: {
      tw: ' opacity-70',
      style: {
        fontFamily: 'Geist',
      },
      children: SITE.TITLE,
    },
  }

  const separatorElement = {
    type: 'div',
    props: {
      tw: 'px-2 opacity-70',
      children: '•',
    },
  };

  const siteSubtitleElement = {
    type: 'div',
    props: {
      tw: 'opacity-70',
      style: {
        fontFamily: 'Geist',
      },
      children: title,
    },
  };

  const siteImageAndTitleContainer = {
    type: 'div',
    props: {
      tw: 'flex items-center justify-start w-full mb-2',
      children: [siteImageElement, siteTitleElement, separatorElement, siteSubtitleElement],
    },
  };
 
  let imageElement = null
  let shouldBlur = false
  if (image && shouldBlur) {
    const img = fs.readFileSync(
      process.env.NODE_ENV === 'development'
        ? path.resolve(image.src.replace(/\?.*/, '').replace('/@fs', ''),)
        : path.resolve(image.src.replace('/', 'dist/')),
    );
    const imgData = await getPixels(new Uint8Array(img));
    const data = Uint8ClampedArray.from(imgData.data);
    const blurhash = encode(data, imgData.width, imgData.height, 4, 4);
    const gradient = blurhashToCssGradientString(blurhash, 5, 5);

    imageElement = {
      type: 'div',
      props: {
        tw: 'w-full h-full absolute inset-0 rotate-90',
        style: {
          opacity: 0.07,
          background: gradient,
        },
      },
    };
  }

  const gradientElement = {
    type: 'div',
    props: {
      style: {
        background: 'radial-gradient(circle at bottom right, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0))',
      },
      tw: 'w-full h-full absolute flex inset-0 opacity-40',
    },
  };

  const mainTextElement = {
    type: 'div',
    props: {
      tw: 'text-5xl leading-none text-left',
      style: {
        fontFamily: 'Geist',
        fontWeight: '400',
      },
      children: text,
    },
  };

  const containerElement = {
    type: 'div',
    props: {
      tw: 'flex flex-col items-center justify-center max-w-3xl w-full',
      children: [
        siteImageAndTitleContainer,
        {
          type: 'div',
          props: {
            tw: 'shrink flex w-full',
            children: [
              mainTextElement,
            ],
          },
        },
      ],
    },
  }

  const footerElement = {
    type: 'div',
    props: {
      tw: 'absolute right-[15px] bottom-[15px] flex items-center justify-center opacity-70 text-xs',
      children: [
        SITE.URL_SHORT,
      ],
    },
  }

  const html = {
    type: 'div',
    props: {
      tw: 'relative w-full h-full flex flex-col items-center justify-center relative relative text-white',
      style: {
        background: '#0a0a0a',
        fontFamily: 'Geist',
        fontSmoothing: 'antialiased',
      },
      children: [
        imageElement,
        gradientElement,
        containerElement,
        footerElement,
      ],
    },
  } as any


  const weights = [100, 200, 300, 400, 900] as const

  const fontConfigs = [
    ...weights.map(weight => ({ name: 'Geist', weight })),
    ...weights.map(weight => ({ name: 'Geist Mono', weight })),
  ]

  const fonts = await Promise.all(
    fontConfigs.map(async ({ name, weight }) => ({
      name,
      data: await loadFont(name, text, weight),
      style: 'normal' as const,
      weight,
    }))
  )

  return new ImageResponse(html, {
    width: 1200,
    height: 630,
    fonts,
    debug: false,
  })
}

/**
 * Generates a SEO image for a blog post or project
 * @param entry - The entry to generate the SEO image for
 * @param type - The type of the entry
 * @returns The SEO image
 */
export interface SeoImageGeneratorForContentProps {
  entry: CollectionEntry<"blog"> | CollectionEntry<"projects">;
  type: CollectionKey
}

export async function generateSeoImageForContent({ entry, type }: SeoImageGeneratorForContentProps) {
  const {
    title: text,
    ...rest
  } = entry.data;

  const title = {
    blog: 'Blog',
    projects: 'Projects',
  }[type]

  return generateSeoImage({
    title,
    text,
    image: entry.data.cover,
    ...rest,
  })
}

Let’s break down what we’ve done in this file to keep it simple and maintainable:

  • Create multiple elements, we could inline everything, but to keep it easy to manage iv went this way
  • Use tailwindcss classes to style the elements ( keep in mind not every utility is supported, but most of them are )
  • We can also pull and attach images from the entry data, like the cover image, a logo or favicon if needed.
  • Blurhash to generate a gradient background for the image, this is optional, but it looks nice and gives a nice touch to the image without looking always the same color/style.
  • Loading Fonts from Google Fonts directly, we using Geist here. You can also load local fonts if needed.
  • Finally, we return the ImageResponse, which is the image that will be used as the SEO image.

This is all it takes to create a generic image generator, you can tweak it to your own needs and get more information going on.

Now lets see how we serve this image, probably the easiest part!

3. Serving the Image 🚀

3.1. TanStack Start

With TanStack Start, you’ll want to create a server route to serve the image.

For a blog post, we could create a route under src/routes/blog/$slug[.]png.ts

Note: If you’re using virtual routes, don’t forget to add it to your routes configuration.

import { generateSeoImageForContent } from '@/lib/seo-image-generator'

export const ServerRoute = createServerFileRoute().methods({
  GET: async ({ request, params }) => {
    // Call your database to get article by the slug for example
    const article = await db.query.articles.findFirst({where: and(eq(tables.articles.slug, params.slug))})

    if (!article) {
      return new Response('Article not found', { status: 404 })
    }
    
    return await generateSeoImageForContent({ entry: article, type: 'blog' })
  }
})

That’s it! We have a server route that generates the image and returns it as a response. We’ll explore below what we can do to optimize this further.

3.2. Astro

With Astro, you’ll want to create a server route to serve the image.

For a blog post, we could create a route under src/pages/blog/[slug].png.ts

import { getCollection, type CollectionEntry } from 'astro:content'
import type { APIRoute } from 'astro'
import { generateSeoImageForContent } from "@lib/seo-image-generator"

type MaybeCollectionEntry = CollectionEntry<"blog"> | CollectionEntry<"projects">

export async function getStaticPaths() {
	const posts = await getCollection('blog')
	return posts.map((post) => ({
		params: { id: post.id },
		props: post,
	}))
}

export const GET: APIRoute = async ({ props }) => {
	const image = await generateSeoImageForContent({
		entry: props as MaybeCollectionEntry,
		type: 'blog',
	})
	return image
}

That’s it! When you generate your Astro site, you’ll have a route that generates the image and copies your images to the dist folder.

3.3. Laravel / PHP

While this could be more challenging in PHP land, there are a few options. We can still achieve this with Laravel. In this case, we’ll create a Bun script to generate the image, then use the Spatie Media Library to store the image.

For a blog post, we could create an action under app/Actions/GenerateBlogPostOGImageAction.php

<?php

namespace App\Actions;

use App\Enums\MediaCollectionEnum;
use App\Enums\System\FilesystemDiskEnum;
use App\Models\Media;
use App\Models\BlogPost;
use Illuminate\Support\Str;
use Spatie\MediaLibrary\MediaCollections\Exceptions\FileDoesNotExist;
use Spatie\MediaLibrary\MediaCollections\Exceptions\FileIsTooBig;
use Symfony\Component\Process\Process;

final class GenerateBlogPostOGImageAction
{
    /**
     * @throws FileDoesNotExist
     * @throws FileIsTooBig
     */
    public function handle(BlogPost $blogPost): void
    {
        // Remove any existing og images
        // @phpstan-ignore-next-line
        $blogPost->getMedia(MediaCollectionEnum::OGImage->value)->each(fn (Media $media) => $media->delete());

        $avatar = $blogPost->getFirstMedia(MediaCollectionEnum::Avatar->value)?->getUrl();
        $temporaryFile = tempnam(sys_get_temp_dir(), MediaCollectionEnum::OGImage->value);

        $parameters = [
            config('app.bun_path', 'bun'), base_path('scripts/og-image.ts'),
            '--output', $temporaryFile,
            '--title', $blogPost->title,
            '--description', $blogPost->description,
            ...($avatar ? ['--avatar', $avatar] : []),
        ];

        $process = new Process($parameters, env: [
            'NODE_TLS_REJECT_UNAUTHORIZED' => '0',
        ]);

        $process->run();
        if ($process->isSuccessful()) {
            $blogPost
                ->addMedia($temporaryFile)
                ->usingName($blogPost->uuid)
                ->usingFileName($blogPost->uuid.'.png')
                ->toMediaCollection(MediaCollectionEnum::OGImage->value, FilesystemDiskEnum::OGImages->value);
        } else {
            logger($process->getErrorOutput());
        }
        @unlink($temporaryFile);
    }
}

4. Optimizing & Performance ⚡

While this is great and super fast, and we’ve got our dynamic image sorted out, sometimes we can optimize this further - especially if the image doesn’t change often.

With Astro, you can use the getStaticPaths() function to generate the paths for images. When you build to a static site, Astro will generate these images beforehand, so no further requests will be made to the server.

For TanStack Start with a more “dynamic” approach, I’d recommend a few strategies:

  • Generate or cache the image on the server - serve cached version if it already exists
  • Set caching headers so the image is cached longer in the browser
  • Use a CDN to cache the image globally for faster delivery

5. Conclusion

That’s about it! I hope you enjoyed this article, and if you have any questions, feel free to reach out to me on @nikuscs.

Thanks for reading! Special thanks to @shuding for creating Satori 🙏

6. Resources 📚

Like this post? Sharing it means a lot to me! ❤️