目次

はじめに

長いことこのブログをほったらかしていましたが、1年以上前から記事ごとのOGP画像の自動生成をやろうとしており、最近になってようやく実装しました。

このブログはAstroでSSGされており、OGPの自動生成処理にはAstroの動的ルーティングとSatoriを使用しました。

トップページや記事一覧ページは元の画像のままで、記事ページだけ記事タイトル付きの新しいOGP画像を設定しています。

この記事では次のような画像が自動生成されます。

この記事で自動生成されたOGP画像のプレビュー

Satoriとは

SatoriとはHTML+CSSをSVGに変換するJSライブラリで、VercelがOSSとしてメンテナンスしています。SatoriはJSXをサポートしているので、実際にはJSXをSVGに変換する方法で多く利用されていそうに思います。

今回はこのSatoriと、SVGをPNGに変換できる@resvg/resvg-jsを組み合わせています。SVGをPNGに変換するライブラリは@resvg/resvg-jsではなくsharpなどもありますが、Vercel上で動的にOGP画像を生成する@vercel/ogというライブラリも内部的にこの組み合わせで実装されているようなので、今回はこの組み合わせで実装しました。

実装方法

AstroとSatoriでOGP画像を生成する方法はよく紹介されていて今更感があるので、今回の実装の一部を抜粋しながらざっくりと紹介します。

全ての実装を見たい場合は下記のPull Requestを参考にしてください。

https://github.com/kimulaco/blog/pull/135

利用するパッケージのインストール

JSX → SVG → PNG のように変換するためのパッケージをインストール

npm i -D satori @resvg/resvg-js

今回はAstroのビルドでJSXもビルドする必要があるので、AstroでReactのセットアップがされていない場合は次のコマンドでReact関連のパッケージのインストールやconfigの更新を行います。

npx astro add react

今回は次のバージョンで実装しています。

  • satori: 0.12.2
  • @resvg/resvg-js: 2.6.2
  • react: 19.1.0
  • astro: 5.7.4
  • typescript: 5.8.3

ちなみに、このブログは以前Nuxt.jsで実装されていたものをAstroに移植した背景があり、一部コンポーネントはVue.jsで実装されています。今回の対応でJSXを使いたかったのでReactも追加しましたが、簡単に異なるUIフレームワークを混ぜることができるAstroの強みも出ました。

OGP画像の生成処理

一部抜粋ですが、次のようにOGP画像のReactコンポーネントと、コンポーネントをPNGに変換する実装の一例です。

// core/ogp/index.ts
import fs from 'fs'
import path from 'path'
import satori from 'satori'
import { Resvg } from '@resvg/resvg-js'
import type { FC } from 'react'

type ArticleOGPProps = {
  title: string
  iconBase64: string
}

const ArticleOGP: FC<ArticleOGPProps> = ({ title, iconBase64 }) => {
  return (
    <div style={{ width: '100%' }}>
      <!-- OGP画像のデザインマークアップJSX -->
    </div>
  )
}

const fontData = fs.readFileSync(path.resolve(process.cwd(), 'src/assets/font/NotoSansJP.ttf'))
const iconBase64 = fs.readFileSync(path.resolve(process.cwd(), 'src/assets/img/icon-logo.png')).toString('base64')

export const generateOgpImage = async (title: string) => {
  const svg = await satori(
    <ArticleOGP title={title} iconBase64={iconBase64} />,
    {
      width: 1200,
      height: 630,
      fonts: [{
        name: 'Noto Sans JP',
        data: fontData,
        style: 'normal',
        weight: 700,
      }],
    }
  )

  const resvg = new Resvg(svg, {
    fitTo: {
      mode: 'width',
      value: 1200,
    },
  })
  const image = resvg.render()

  return image.asPng()
}

OGPのエンドポイントを作る

全ての記事ページのOGP画像を生成するために、次のようにAstroの動的ルーティングを新たに追加します。

// src/pages/article/[id]/ogp.png.ts
import type { APIRoute } from 'astro'
import { generateOgpImage } from '@/core/ogp'
import { getArticleDetail, getAllArticles } from '@/core/article'

export const GET: APIRoute = async ({ params }) => {
  const articleId = params.id

  if (!articleId) {
    return new Response(null, {
      status: 404,
      statusText: 'Not Found',
    })
  }

  const article = await getArticleDetail(articleId)
  const png = await generateOgpImage(article.title)

  return new Response(png, {
    headers: {
      'Content-Type': 'image/png',
    },
  })
}

// 全ての記事ページ分のOGP画像のエンドポイントを生成
export const getStaticPaths = async () => {
  const articles = await getAllArticles()
  return articles.map((article) => ({
    params: {
      id: article.id,
    },
  }))
}

getArticleDetail()getAllArticles()の仕様は今回重要ではないので深掘りしませんが、getArticleDetail()から取得した記事タイトルをOGP画像生成関数にわたしているのと、getAllArticles()で取得した全ての記事分のファイルを生成しています。

あとは記事ページ側で、記事ごとにOGP画像のパスを調整すれば良いです。

Vercel の OG Image Playground が便利だった

OGP画像のJSXを作成する時に、いきなりアプリケーション内で試してもいいのですが、Vercel の OG Image Playgroundを使うのが便利でした。

OG Image Playgroundでは、ブラウザ上でJSXの編集エディタと画像プレビューがセットになっており、要素ごとのアウトラインを可視化できるデバッグモードやPNGまで変換した時のプレビューなど、機能が多彩です。

OG Image PlaygroundでとりあえずOGP画像のデザインの雛形を作り、実装に移植していくのが楽でした。

まとめ

  • Astro製ブログにSatoriを追加して、記事ごとのOGP画像の自動生成機能を実装しました
  • SatoriはHTMLやJSXをSVGに変換できるライブラリ
  • Satoriで変換するOGP画像のJSXの叩きはOG Image Playgroundで作るのが便利でした