動的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-canvas | Canvasで描画。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にはリソース制限がある。
| 項目 | Free | Paid |
|---|---|---|
| CPU時間 | 10ms | 50ms(Unbound: 30s) |
| メモリ | 128MB | 128MB |
| スクリプトサイズ | 1MB | 10MB |
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 Validator | https://cards-dev.twitter.com/validator |
| Facebook Sharing Debugger | https://developers.facebook.com/tools/debug/ |
| LinkedIn Post Inspector | https://www.linkedin.com/post-inspector/ |
デバッグ手順:
- OGP画像URLに直接アクセスして画像が表示されるか確認
- 各SNSのデバッガーでメタタグが正しく認識されるか確認
- キャッシュが古い場合は「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/og | Satori + 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画像生成は、クイズ結果だけでなくユーザープロフィール、証明書、ランキングなど応用範囲が広い。