2026年7月4日

2026年7月4日

WordPressのOGP画像をSvelteKitで動的生成してVercelにデプロイする方法

WordPressの記事ごとにOGP画像(SNSでシェアされたときに表示される画像)を自動で作るには、SvelteKitで「記事IDを受け取り、WordPressのREST APIからタイトルを取得して、PNG画像を返す」エンドポイントを作り、Vercelにデプロイして、WordPress側の og:image にそのURLを指定します。この記事では、satoriとresvgを使った画像生成の実装、日本語フォントの扱い、キャッシュの設定、WordPress側でのメタタグ出力、確認方法までを順に説明します。WordPressを通常どおり表示しているサイトでも、ヘッドレス構成でも使える構成です。

全体の構成

処理の流れは次のとおりです。

  1. SNSのクローラーが記事ページの og:image を読み、https://og.example.com/og/123?v=1760000000 のような画像URLにアクセスする
  2. VercelにデプロイしたSvelteKitのエンドポイントが、WordPressのREST API(/wp-json/wp/v2/posts/123)から記事タイトルを取得する
  3. satoriでHTML風の要素ツリーをSVGに変換し、resvgでPNGに変換して返す
  4. VercelのCDNがレスポンスをキャッシュし、2回目以降は生成せずに返す

画像の文字はURLのクエリで受け取らず、記事IDからWordPressに問い合わせて決めます。URLにタイトルをそのまま入れる設計だと、第三者が任意の文字列を入れた画像を自分のドメインで生成できてしまうためです。REST APIは未ログインのリクエストには公開済みの記事しか返さないので、下書きのタイトルが画像化されることもありません。

準備

作業の前提は次のとおりです。ツールのバージョンや仕様は更新が多いため(2026年10月現在)、各公式ドキュメントで最新の手順を確認してください。

  • Node.jsの開発環境とGitリポジトリ(GitHubなど)
  • Vercelのアカウント
  • WordPressのREST APIが外部から読めること(セキュリティプラグインでREST APIを制限している場合は、投稿の読み取りを許可する)
  • 日本語フォントのファイル(例: Noto Sans JP のTTFまたはOTF)。satoriはWOFF2を読めないため、TTF・OTF・WOFFを用意します。フォントのライセンスも確認します

SvelteKitのプロジェクトを作り、必要なパッケージを入れます。

npx sv create og-image
cd og-image
npm install satori @resvg/resvg-js
npm install -D @sveltejs/adapter-vercel

svelte.config.js のアダプターを @sveltejs/adapter-vercel に変えます。既定の adapter-auto でもVercelを検出しますが、明示しておくと関数の設定を後から変えやすくなります。

// svelte.config.js
import adapter from '@sveltejs/adapter-vercel';

export default {
	kit: { adapter: adapter() }
};

画像を生成するエンドポイント

フォントは src/lib/fonts/NotoSansJP-Bold.ttf に置きます。src/routes/og/[id]/+server.ts を作ります。

// src/routes/og/[id]/+server.ts
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
import { error } from '@sveltejs/kit';
import { read } from '$app/server';
import { env } from '$env/dynamic/private';
import fontFile from '$lib/fonts/NotoSansJP-Bold.ttf';
import type { RequestHandler } from './$types';

let fontData: ArrayBuffer | null = null;

function decodeEntities(s: string): string {
	return s
		.replace(/<[^>]*>/g, '')
		.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
		.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
		.replace(/&quot;/g, '"')
		.replace(/&lt;/g, '<')
		.replace(/&gt;/g, '>')
		.replace(/&amp;/g, '&');
}

export const GET: RequestHandler = async ({ params, fetch }) => {
	if (!/^\d{1,10}$/.test(params.id)) error(404, 'Not found');

	const res = await fetch(
		`${env.WP_API_BASE}/wp-json/wp/v2/posts/${params.id}?_fields=title`
	);
	if (!res.ok) error(404, 'Not found');
	const post = await res.json();

	let title = decodeEntities(post?.title?.rendered ?? '');
	if (title.length > 60) title = title.slice(0, 59) + '…';

	fontData ??= await read(fontFile).arrayBuffer();

	const tree = {
		type: 'div',
		props: {
			style: {
				width: '1200px',
				height: '630px',
				display: 'flex',
				flexDirection: 'column',
				justifyContent: 'space-between',
				padding: '72px',
				background: '#0f172a',
				color: '#ffffff',
				fontFamily: 'NotoSansJP'
			},
			children: [
				{ type: 'div', props: { style: { fontSize: 60, lineHeight: 1.35 }, children: title } },
				{ type: 'div', props: { style: { fontSize: 30, opacity: 0.8 }, children: 'サイト名' } }
			]
		}
	};

	const svg = await satori(tree as any, {
		width: 1200,
		height: 630,
		fonts: [{ name: 'NotoSansJP', data: fontData, weight: 700, style: 'normal' }]
	});
	const png = new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } }).render().asPng();

	return new Response(new Uint8Array(png), {
		headers: {
			'Content-Type': 'image/png',
			'Cache-Control': 'public, max-age=3600, s-maxage=604800'
		}
	});
};

コードのポイント

  • IDの検証:数字以外のIDは404にします。WordPressへの問い合わせ先は環境変数 WP_API_BASE(例: https://example.com)で固定し、リクエストから組み立てないようにします。
  • タイトルの整形:REST APIの title.rendered は、&#8217; のような文字参照やタグを含むことがあります。画像に描くのは文字列なので、タグを除いて文字参照を戻しています。長いタイトルは途中で切ります。何文字で切るかは、実際に生成して見た目で決めます。
  • satoriの要素ツリー:satoriは本来JSXを受け取りますが、type と props を持つオブジェクトでも書けます。子要素が複数ある div には display: 'flex' が要ります。CSSは一部のみ対応なので、使えるプロパティはsatoriのドキュメントで確認します。
  • フォントの読み込み:$app/server の read() は、SvelteKit 2系の比較的新しい版で使える、インポートしたファイルをサーバー側で読む関数です。使えない版では、フォントを外部のURLから fetch で取得する方法もあります。読み込んだフォントは変数に保持し、同じ実行環境の中では再利用します。
  • ランタイム:@resvg/resvg-js はネイティブモジュールなので、VercelのNode.jsランタイムで動かします。Edgeランタイムで動かす場合は、WebAssembly版のパッケージに切り替える必要があります。

日本語フォントはファイルが大きく、関数の容量や起動時間に影響します。使う文字が限られるならサブセット化したフォントを使う、太さを1種類に絞るといった工夫が有効です。絵文字は別途画像を読み込む設定をしないと表示されないことがあります。

Vercelにデプロイする

  1. リポジトリをGitHubなどにpushし、Vercelの管理画面でプロジェクトとしてインポートする(Vercel CLIの vercel コマンドでも可能です)
  2. プロジェクトの環境変数に WP_API_BASE を設定する
  3. デプロイ後、https://<プロジェクトのドメイン>/og/<公開済み記事のID> をブラウザで開き、PNGが表示されるか確認する
  4. 必要に応じて og.example.com のような独自ドメインを割り当てる

画面の名称や設定場所は変わることがあるため、Vercelのドキュメントに沿って進めてください。料金や無料枠の範囲もプランによって異なるため、関数の実行回数が増える使い方では事前に確認します。

キャッシュの考え方

s-maxage はCDN(Vercelのエッジ)向けのキャッシュ時間、max-age はブラウザ向けです。OGP画像は一度生成すれば変わらないことが多いため、CDNでは長めに保持して関数の実行回数を抑えます。

タイトルを変えたときに古い画像が残らないよう、WordPress側で画像URLに記事の更新日時を ?v= として付けます。URLが変わればCDNは別の画像として扱います。ただし、SNS側も画像をキャッシュするため、すでにシェアされた投稿の表示がすぐに変わるとは限りません。

WordPress側で og:image を出力する

SEOプラグインを使っていない場合は、独自プラグインやmu-pluginで wp_head にメタタグを出力します。URLは esc_url() でエスケープします。

<?php
/**
 * Plugin Name: OG Image from SvelteKit
 */
defined( 'ABSPATH' ) || exit;

add_action( 'wp_head', function () {
	if ( ! is_singular( 'post' ) ) {
		return;
	}
	$id  = get_queried_object_id();
	$ver = get_post_modified_time( 'U', true, $id );
	$url = 'https://og.example.com/og/' . $id . '?v=' . $ver;

	printf( '<meta property="og:image" content="%s" />' . "\n", esc_url( $url ) );
	echo '<meta property="og:image:width" content="1200" />' . "\n";
	echo '<meta property="og:image:height" content="630" />' . "\n";
	echo '<meta name="twitter:card" content="summary_large_image" />' . "\n";
} );

Yoast SEOなどのSEOプラグインを使っている場合は、プラグインも og:image を出力するため、上のコードを足すとタグが重複します。プラグインが用意しているフィルター(Yoast SEOなら wpseo_opengraph_image など)で画像URLを差し替えるか、プラグインの設定で既定の画像を指定する方法を、各プラグインのドキュメントで確認してください。フィルターの呼ばれ方はプラグインの版や記事にアイキャッチがあるかどうかで変わることがあります。

ヘッドレス構成でフロントエンドもSvelteKitで作っている場合は、記事ページの +page.svelte の <svelte:head> で同じメタタグを出力します。

確認方法

  1. 記事ページのHTMLソースで、og:image が1つだけ出力され、URLが正しいかを確認する
  2. 画像URLを直接開き、日本語が文字化け(豆腐)せず、長いタイトルが枠内に収まっているかを確認する
  3. 各SNSが提供しているカード・シェアの確認ツールでプレビューを確認する。確認ツールの提供状況や名称は変わることがあるため、各SNSの開発者向けドキュメントを参照します
  4. 開発者ツールで画像のレスポンスヘッダーを見て、Content-Type: image/png と、2回目のアクセスでCDNのキャッシュから返っていることを確認する

まとめ

  • SvelteKitのエンドポイントで記事IDを受け取り、WordPressのREST APIからタイトルを取得して、satoriとresvgでPNGを生成します。文字はURLのクエリから受け取らないようにします。
  • 日本語フォントはTTF・OTF・WOFFで用意し、サイズとライセンスに注意します。resvgのネイティブ版はNode.jsランタイムで動かします。
  • s-maxage でCDNにキャッシュさせ、WordPress側では更新日時を ?v= に付けて差し替えに対応します。
  • og:image は esc_url() でエスケープして出力し、SEOプラグインとの重複を避けます。
  • SvelteKit・satori・Vercelは更新が早いため、導入時に公式ドキュメントで手順を確認し、プレビュー環境で表示を確かめてから本番に反映してください。

お気軽にご相談ください

お見積りへ お問い合わせへ
▲