• #ogp
  • #satori
  • #cloudflare-workers
  • #dynamic-image
  • #nuxt
未分類

動的OGP画像生成の実装ガイド

この記事の背景

クイズアプリで「8/10問正解しました!」のような結果をSNSでシェアする際に、結果に応じたOGP画像を表示したい。しかしユーザーごとに結果が異なるため、静的な画像では対応できない。

解決策: リクエストパラメータに応じてOGP画像を動的に生成するAPIを実装する。


静的OGP vs 動的OGP

項目静的OGP動的OGP
生成タイミングビルド時リクエスト時
画像の種類固定(記事タイトル等)可変(ユーザー結果等)
パフォーマンス最速(CDN配信)やや遅い(生成処理)
柔軟性低い高い
ブログ記事のOGPクイズ結果、プロフィールカード

いつ動的OGPが必要か?

  • クイズ・テストの結果シェア
  • ユーザープロフィールカード
  • リアルタイムデータ(株価、天気など)
  • パーソナライズされたコンテンツ

技術スタック

┌─────────────────────────────────────────────────────────┐
│  リクエスト                                              │
│  /api/og?title=結果&score=8&total=10                    │
└─────────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────────┐
│  Satori                                                  │
│  JSX/React-like構文 → SVG に変換                         │
│  (HTML/CSSのサブセットをサポート)                       │
└─────────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────────┐
│  Resvg                                                   │
│  SVG → PNG に変換(ラスタライズ)                        │
│  (Rustベースで高速)                                    │
└─────────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────────┐
│  PNG画像をレスポンス                                     │
│  Content-Type: image/png                                 │
└─────────────────────────────────────────────────────────┘

Q&A

Q1: Satoriとは何か?なぜ選ばれるのか?

A: Vercelが開発したライブラリであるSatoriは、JSX/HTML+CSSをSVGに変換する。

選ばれる理由:

  • React/JSXライクな構文でデザインを記述できる
  • Flexboxレイアウトをサポート
  • エッジランタイム(Cloudflare Workers等)で動作
  • @vercel/ogの内部で使われている実績

代替手段:

ライブラリ特徴
Puppeteer/Playwrightブラウザでレンダリング。重い。エッジ不可
node-canvasCanvasで描画。Node.js依存
Sharp画像処理特化。テキスト描画は弱い
Satori軽量、エッジ対応、JSX構文

Q2: 日本語フォントの埋め込みはどうやるのか?

A: Satoriはフォントファイルを直接読み込んで埋め込む。

import satori from 'satori'

// フォントファイルを読み込み(ArrayBuffer形式)
const fontData = await fetch('https://example.com/NotoSansJP-Bold.ttf')
  .then(res => res.arrayBuffer())

const svg = await satori(
  <div style={{ fontFamily: 'Noto Sans JP' }}>こんにちは</div>,
  {
    width: 1200,
    height: 630,
    fonts: [
      {
        name: 'Noto Sans JP',
        data: fontData,
        weight: 700,
        style: 'normal',
      },
    ],
  }
)

注意点:

  • 日本語フォントは大きい(Noto Sans JP Regular: 約5MB)
  • サブセット化で軽量化可能(後述)
  • Cloudflare Workersのメモリ制限に注意

Q3: フォントのサブセット化とは?

A: 使用する文字だけを抽出してフォントファイルを軽量化する技術。

Noto Sans JP Full:    約5MB(全文字)
サブセット化後:       約100KB〜500KB(必要な文字のみ)

サブセット化ツール:

クイズ結果の場合:

必要な文字: 0123456789問正解/修飾語の順序句読点助詞使い方クイズ
→ 数十文字程度なので大幅に軽量化可能

Q4: Resvgとは?なぜSVG→PNGの変換が必要?

A: Rustで書かれたSVGレンダラーであるResvgは、SVGをPNG等のラスター画像に変換する。

なぜ変換が必要か:

  • SNS(Twitter/X、Facebook等)はOGP画像としてSVGをサポートしていない
  • PNG/JPEGのみ対応
  • SatoriはSVGまでしか生成しないため、PNG変換が必要
import { Resvg } from '@resvg/resvg-js'

const svg = await satori(/* ... */)

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

Q5: Satoriだけでは動的OGPは完成しないのか?

A: いいえ、Satoriだけでは完成しない。Resvgが必須だ。

処理フロー:
┌─────────┐      ┌─────────┐      ┌─────────┐
│ Satori  │ ──→  │   SVG   │ ──→  │  Resvg  │ ──→  PNG
└─────────┘      └─────────┘      └─────────┘
   JSX/CSS        中間形式          ラスタライズ

Satoriの役割:

  • JSX/HTML+CSSをSVGに変換
  • SVGはベクター形式(テキストデータ)

Resvgが必要な理由:

  • SNS(Twitter/X、Facebook等)はOGP画像としてSVGをサポートしていない
  • PNG/JPEGのラスター形式のみ対応
  • SatoriはSVGまでしか生成できないため、Resvgでラスタライズ(PNG変換)が必須

よくある誤解:

「Satoriで画像が生成されるならそのまま使えるのでは?」

→ SatoriはSVG(XMLテキスト)を生成するだけ。ブラウザでは表示できますが、SNSのOGP画像には使えません。

必要なパッケージ:

{
  "dependencies": {
    "satori": "^0.18.3",           // JSX → SVG
    "@resvg/resvg-js": "^2.6.2"    // SVG → PNG
  }
}

Q6: Cloudflare Workersの制限は?

A: Cloudflare Workersにはリソース制限がある。

項目FreePaid
CPU時間10ms50ms(Unbound: 30s)
メモリ128MB128MB
スクリプトサイズ1MB10MB

OGP生成での注意点:

  • フォントファイルが大きいとスクリプトサイズ制限に抵触
  • 複雑なSVG生成はCPU時間を消費
  • Resvgのラスタライズも負荷がかかる

対策:

  • フォントをサブセット化
  • フォントを外部(R2、CDN)から取得
  • 生成結果をキャッシュ

Q7: キャッシュ戦略は?

A: 同じパラメータなら同じ画像が生成されるため積極的にキャッシュすべきだ。

// Cloudflare Workersでのキャッシュ例
export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
    const cache = caches.default
    const cacheKey = new Request(request.url, request)

    // キャッシュチェック
    let response = await cache.match(cacheKey)
    if (response) {
      return response
    }

    // OGP画像生成
    const png = await generateOgImage(request)

    response = new Response(png, {
      headers: {
        'Content-Type': 'image/png',
        'Cache-Control': 'public, max-age=86400', // 24時間
      },
    })

    // キャッシュに保存
    ctx.waitUntil(cache.put(cacheKey, response.clone()))

    return response
  },
}

キャッシュキーの設計:

/api/og?category=modification-order&correct=8&total=20
         ↓
キャッシュキー: og:modification-order:8:20

Q8: Nuxtで動的にOGPメタタグを設定する方法は?

A: useHeadまたはuseSeoMetaを使用する。

<script setup lang="ts">
const route = useRoute()
const { category, correct, total } = route.query

useSeoMeta({
  ogImage: `https://eurekapu.com/api/og/japanese-quiz?category=${category}&correct=${correct}&total=${total}`,
  ogTitle: `${getCategoryName(category)}クイズ - ${correct}/${total}問正解!`,
  ogDescription: '日本語作文クイズに挑戦しよう',
  twitterCard: 'summary_large_image',
})
</script>

注意: SSRが必要。クライアントサイドでメタタグを変更してもクローラーには見えない。


Q9: Twitter/Xカードの検証・デバッグ方法は?

A: 以下のツールを使用する。

ツールURL
Twitter Card Validatorhttps://cards-dev.twitter.com/validator
Facebook Sharing Debuggerhttps://developers.facebook.com/tools/debug/
LinkedIn Post Inspectorhttps://www.linkedin.com/post-inspector/

デバッグ手順:

  1. OGP画像URLに直接アクセスして画像が表示されるか確認
  2. 各SNSのデバッガーでメタタグが正しく認識されるか確認
  3. キャッシュが古い場合は「Scrape Again」で更新

よくある問題:

  • 画像が表示されない → URLが公開アクセス可能か確認
  • 古い画像が表示される → SNS側のキャッシュ。デバッガーで更新
  • サイズが合わない → 1200x630pxを推奨

Q10: @vercel/ogとの違いは?

A: @vercel/ogはVercelが提供するOGP画像生成のオールインワンパッケージだ。

// @vercel/og(Vercel専用)
import { ImageResponse } from '@vercel/og'

export const config = { runtime: 'edge' }

export default function handler() {
  return new ImageResponse(
    <div>Hello World</div>,
    { width: 1200, height: 630 }
  )
}

比較:

項目@vercel/ogSatori + Resvg
対象環境Vercel Edge Functionsどこでも
セットアップ簡単(オールインワン)手動設定必要
カスタマイズ限定的柔軟
Cloudflare対応

結論: Vercel以外(Cloudflare等)では Satori + Resvg を使用。


Q11: Cloudflare Pagesでも動くのか?

A: はい、Cloudflare Pages Functions(内部的にWorkers)で動作する。

apps/web/
└── server/
    └── api/
        └── og/
            └── japanese-quiz.ts  ← Nitroがこれを自動的にWorkerに変換

NuxtのNitroプリセットcloudflare_pagesを使用している場合、server/api/配下のファイルは自動的にPages Functionsとしてデプロイされる。


実装例(Nuxt + Cloudflare Pages)

// server/api/og/japanese-quiz.ts
import satori from 'satori'
import { Resvg } from '@resvg/resvg-js'

export default defineEventHandler(async (event) => {
  const { category, correct, total } = getQuery(event)

  // カテゴリ名のマッピング
  const categoryNames: Record<string, string> = {
    'modification-order': '修飾語の順序',
    'punctuation': '句読点の打ち方',
    'particles': '助詞の使い方',
  }

  const categoryName = categoryNames[category as string] || 'クイズ'

  // フォント読み込み(事前にサブセット化推奨)
  const fontData = await fetch(
    'https://your-cdn.com/fonts/NotoSansJP-Bold-subset.ttf'
  ).then(res => res.arrayBuffer())

  // SVG生成
  const svg = await satori(
    {
      type: 'div',
      props: {
        style: {
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
          justifyContent: 'center',
          background: 'linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%)',
          fontFamily: 'Noto Sans JP',
          color: 'white',
        },
        children: [
          {
            type: 'div',
            props: {
              style: { fontSize: 48, marginBottom: 20 },
              children: `📝 ${categoryName}クイズ`,
            },
          },
          {
            type: 'div',
            props: {
              style: { fontSize: 72, fontWeight: 'bold' },
              children: `🎯 ${correct} / ${total} 問正解`,
            },
          },
          {
            type: 'div',
            props: {
              style: {
                position: 'absolute',
                bottom: 40,
                right: 40,
                fontSize: 24,
                opacity: 0.8,
              },
              children: 'eurekapu.com',
            },
          },
        ],
      },
    },
    {
      width: 1200,
      height: 630,
      fonts: [
        {
          name: 'Noto Sans JP',
          data: fontData,
          weight: 700,
          style: 'normal',
        },
      ],
    }
  )

  // PNG変換
  const resvg = new Resvg(svg)
  const pngBuffer = resvg.render().asPng()

  // レスポンス
  return new Response(pngBuffer, {
    headers: {
      'Content-Type': 'image/png',
      'Cache-Control': 'public, max-age=86400',
    },
  })
})

まとめ

項目推奨
ライブラリSatori + Resvg
ホスティングCloudflare Workers/Pages
フォントNoto Sans JP(サブセット化)
キャッシュ24時間以上
画像サイズ1200 x 630 px

一度仕組みを作れば様々な場面で再利用できる動的OGP画像生成は、クイズ結果だけでなくユーザープロフィール、証明書、ランキングなど応用範囲が広い。