制作記

自作ブログにHono+@vercel/ogを使って動的にOGPを生成する

Web Astro Hono OGP

このブログは Astro で作成し、Cloudflare Pages でホスティングしています。これまで OGP 画像はサイト共通の 1 枚だけを使っていましたが、ブログ記事を共有したときに記事ごとの内容が伝わりにくかったので、記事タイトルとカテゴリを使って OGP 画像を動的生成する仕組みを追加しました。

この記事では、実際にこのサイトで使っている構成をもとに、Hono と @vercel/og で OGP 画像生成サーバを作り、それを Astro 製のブログから利用するところまでをまとめます。あとで別プロジェクトに流用できるように、生成サーバ側のファイル構成とコード例をやや丁寧に残しておきます。

TL;DR

  • OGP 画像生成本体は、Astro 側ではなく chimolab-portal-og という別の Hono サーバに分離した
  • @vercel/ogImageResponse を使い、titlecategory から 1200 x 630 の PNG を生成する
  • Cloudflare Workers 単体で毎回画像生成する構成は、CPU 時間の制限を考えると不安があった
  • そのため、画像生成は自宅サーバ側で行い、Cloudflare Pages Functions はプロキシとキャッシュに集中させた
  • 生成サーバに障害があっても、既存の /ogp.png にフォールバックするようにした

全体構成

今回作った構成は、ざっくり次のようになっています。

SNS / crawler
  |
  | GET https://chimonakiko.net/og/blog?title=...&category=...
  v
Cloudflare Pages Functions
  |
  | Cache HIT  -> cached PNG
  | Cache MISS -> fetch
  v
OGP image server
  |
  | Hono + @vercel/og
  v
PNG image

本体サイトである chimolab-portal は Astro 製の静的サイトです。一方、OGP 画像を生成する処理は chimolab-portal-og という別プロジェクトに切り出しています。

本体サイトのブログ記事ページでは、記事の titlecategory から /og/blog への URL を生成し、それを og:imagetwitter:image に設定します。/og/blog へのリクエストは Cloudflare Pages Functions が受け取り、キャッシュに画像があればそのまま返し、なければ OGP 画像生成サーバに問い合わせます。

画像生成サーバは https://og.chimonakiko.net/og/blog として公開しています。実体は Hono で作った小さな HTTP サーバで、@vercel/og に JSX を渡して PNG を生成しています。

生成されたOGP画像のサンプル
生成されたOGP画像のサンプル

Workers だけで生成しない理由

最初は Cloudflare Workers だけで OGP 画像生成まで完結させることも考えました。Cloudflare Pages でサイトを公開しているので、OGP 生成も Cloudflare 側に寄せられれば構成としてはきれいです。

ただ、OGP 画像生成は通常の JSON API とは違い、JSX や HTML 相当のレイアウトを最終的に画像へラスタライズする処理です。キャッシュ済みの画像を返すだけなら軽いのですが、リクエストごとに画像を生成する場合は CPU 時間を使います。検討した時点では Workers Free プランの CPU 時間制限内に安定して収めるのが難しそうだったため、Cloudflare 側で毎回生成する構成は避けました。

そこで、画像生成は自宅サーバ側に分離し、Cloudflare Pages Functions は次の責務だけを持つようにしました。

  1. 公開 URL として /og/blog を提供する
  2. titlecategory を正規化する
  3. Cloudflare の Cache API で生成済み PNG をキャッシュする
  4. キャッシュがない場合だけ OGP 画像生成サーバに問い合わせる
  5. 生成サーバが落ちている場合は静的な /ogp.png を返す

この構成なら、重い画像生成処理は自分で管理でき、SNS やクローラーからのアクセスは Cloudflare 側のキャッシュで受けられます。

OGP 画像生成サーバの構成

OGP 画像生成サーバは、別リポジトリの chimolab-portal-og として作っています。ディレクトリ構成は次のようにしました。

chimolab-portal-og
├── scripts/
│   └── build.sh
├── src/
│   ├── assets/
│   │   ├── fonts/
│   │   │   ├── LINESeedJP-Bold.ttf
│   │   │   └── LINESeedJP-Regular.ttf
│   │   └── og_blog_base.png
│   ├── og/
│   │   ├── BlogOgImage.tsx
│   │   ├── assets.ts
│   │   ├── blogOgRoute.tsx
│   │   └── params.ts
│   ├── index.ts
│   └── node.ts
├── package.json
└── tsconfig.json

役割は次のように分けています。

ファイル役割
src/index.tsHono アプリ本体。ルートを登録する
src/node.tsNode.js でサーバを起動するためのエントリポイント
src/og/blogOgRoute.tsx/og/blog/v2 のリクエストを処理し、PNG を返す
src/og/BlogOgImage.tsxOGP 画像の見た目を JSX で定義する
src/og/assets.ts背景画像とフォントを読み込む
src/og/params.tsクエリパラメータを正規化・検証する
scripts/build.shNode.js で動かすための成果物を dist/ に作る

ポイントは、ルーティング、パラメータ検証、アセット読み込み、画像デザインを分けているところです。1 ファイルに全部書くこともできますが、OGP は後からデザインを調整したくなることが多いので、見た目は BlogOgImage.tsx に閉じ込めておくと扱いやすいです。

依存関係

主要な依存関係は次のとおりです。

{
    "dependencies": {
        "@hono/node-server": "^2.0.2",
        "@vercel/og": "^0.11.1",
        "hono": "^4.12.18",
        "react": "^19.2.6"
    }
}

Hono は HTTP ルーティング用、@vercel/og は画像生成用です。@vercel/ogImageResponse は JSX を受け取れるので、OGP 画像のレイアウトを React コンポーネントのように書けます。

開発時は Bun で動かし、本番ではビルドした成果物を Node.js で起動する構成にしています。

bun install
bun run dev

デフォルトでは http://localhost:3000 で起動します。

Hono のルート定義

まず、src/index.ts で Hono アプリを作り、OGP 画像生成用のルートを登録します。

import { Hono } from "hono";

import { blogOgRoute } from "./og/blogOgRoute";

const app = new Hono();

app.get("/", (c) => {
    return c.text("OK");
});

app.get("/og/blog/v2", blogOgRoute);

export default app;

Node.js で起動するための src/node.ts は、Hono アプリを @hono/node-server に渡すだけです。

import { serve } from "@hono/node-server";

import app from "./index";

const DEFAULT_PORT = 3000;
const port = Number.parseInt(process.env.PORT ?? `${DEFAULT_PORT}`, 10);

serve(
    {
        fetch: app.fetch,
        port: Number.isNaN(port) ? DEFAULT_PORT : port,
    },
    (info) => {
        console.log(`Started server: http://localhost:${info.port}`);
    },
);

この形にしておくと、Hono アプリ本体と Node.js の起動処理を分けられます。将来的に別のランタイムへ持っていく場合も、src/index.ts はそのまま使いやすいです。

パラメータの正規化と検証

今回の OGP 画像に必要な情報は、記事タイトルとカテゴリだけです。src/og/params.ts では、titlecategory を受け取り、空文字チェック、空白の正規化、長さ制限を行っています。

const MAX_TITLE_LENGTH = 120;
const MAX_CATEGORY_LENGTH = 40;

export type BlogOgParams =
    | {
          ok: true;
          title: string;
          category: string;
      }
    | {
          ok: false;
          message: string;
      };

const normalizeValue = (value: string | undefined): string => {
    return value?.trim().replace(/\s+/g, " ") ?? "";
};

const truncate = (value: string, maxLength: number): string => {
    return value.length > maxLength ? value.slice(0, maxLength) : value;
};

export const parseBlogOgParams = (url: URL): BlogOgParams => {
    const title = normalizeValue(url.searchParams.get("title") ?? undefined);
    const category = normalizeValue(url.searchParams.get("category") ?? undefined);

    if (!title) {
        return {
            ok: false,
            message: "Missing required query parameter: title",
        };
    }

    if (!category) {
        return {
            ok: false,
            message: "Missing required query parameter: category",
        };
    }

    return {
        ok: true,
        title: truncate(title, MAX_TITLE_LENGTH),
        category: truncate(category, MAX_CATEGORY_LENGTH),
    };
};

この処理は地味ですが、公開エンドポイントとしてかなり重要です。SNS やクローラーからアクセスされる URL なので、異常に長い文字列や空白だけの値が来ても、画像生成処理にそのまま流さないようにしています。

また、ok: trueok: false の union にしておくと、ルート側で成功時と失敗時の処理を分けやすくなります。

背景画像とフォントの読み込み

@vercel/og で日本語をきれいに表示するには、フォントを明示的に渡す必要があります。今回はブログ本体でも使っている LINE Seed JP を同梱し、Regular と Bold を読み込むようにしました。

import { Buffer } from "node:buffer";
import { readFile } from "node:fs/promises";

import type { ImageResponseOptions } from "@vercel/og";

const readAsset = async (url: URL): Promise<ArrayBuffer> => {
    const asset = await readFile(url);

    return asset.buffer.slice(asset.byteOffset, asset.byteOffset + asset.byteLength) as ArrayBuffer;
};

const baseImagePromise = readAsset(new URL("../assets/og_blog_base.png", import.meta.url));
const regularFontPromise = readAsset(new URL("../assets/fonts/LINESeedJP-Regular.ttf", import.meta.url));
const boldFontPromise = readAsset(new URL("../assets/fonts/LINESeedJP-Bold.ttf", import.meta.url));

export type BlogOgAssets = {
    baseImageDataUrl: string;
    fonts: NonNullable<ImageResponseOptions["fonts"]>;
};

export const getBlogOgAssets = async (): Promise<BlogOgAssets> => {
    const [baseImage, regularFont, boldFont] = await Promise.all([
        baseImagePromise,
        regularFontPromise,
        boldFontPromise,
    ]);

    return {
        baseImageDataUrl: `data:image/png;base64,${Buffer.from(baseImage).toString("base64")}`,
        fonts: [
            {
                name: "LINE Seed JP",
                data: regularFont,
                weight: 400,
                style: "normal",
            },
            {
                name: "LINE Seed JP",
                data: boldFont,
                weight: 700,
                style: "normal",
            },
        ],
    };
};

ここでは、背景画像を data URL に変換して BlogOgImage に渡しています。@vercel/og の中でローカルファイルパスを直接参照するより、最初に読み込んで data URL として渡した方が扱いが単純でした。

baseImagePromiseregularFontPromise をモジュールスコープで作っているのは、リクエストごとに毎回ファイル読み込みを初期化しないためです。実際の画像生成時には Promise.all() でまとめて待ちます。

OGP 画像の見た目を JSX で定義する

画像の見た目は src/og/BlogOgImage.tsx にまとめています。

type BlogOgImageProps = {
    baseImageDataUrl: string;
    title: string;
    category: string;
};

const categoryColorMap: Record<string, { fg: string; bg: string }> = {
    雑記: {
        fg: "#FFFFFF",
        bg: "#45556C",
    },
    開発: {
        fg: "#FFFFFF",
        bg: "#0092B8",
    },
    電子工作: {
        fg: "#FFFFFF",
        bg: "#F54900",
    },
    AI: {
        fg: "#FFFFFF",
        bg: "#C800DE",
    },
    制作記: {
        fg: "#FFFFFF",
        bg: "#009966",
    },
};

export const BlogOgImage = ({ baseImageDataUrl, title, category }: BlogOgImageProps) => {
    const fgColor = categoryColorMap[category]?.fg ?? "#FFFFFF";
    const bgColor = categoryColorMap[category]?.bg ?? "#45556C";

    return (
        <div
            style={{
                width: "1200px",
                height: "630px",
                position: "relative",
                display: "flex",
                fontFamily: "LINE Seed JP",
            }}
        >
            <img
                src={baseImageDataUrl}
                width="1200px"
                height="630px"
                style={{
                    position: "absolute",
                    inset: 0,
                    width: "1200px",
                    height: "630px",
                    objectFit: "cover",
                }}
                alt="background"
            />

            <span
                style={{
                    position: "absolute",
                    left: "100px",
                    top: "40px",
                    lineHeight: "80px",
                    display: "flex",
                    padding: "0px 40px",
                    borderRadius: "0px 0px 16px 16px",
                    color: fgColor,
                    backgroundColor: bgColor,
                    fontSize: "40px",
                    fontWeight: 700,
                }}
            >
                {category}
            </span>

            <span
                style={{
                    position: "absolute",
                    left: "100px",
                    top: "136px",
                    width: "1000px",
                    height: "320px",
                    color: "#45556C",
                    display: "block",
                    fontSize: "72px",
                    fontWeight: 700,
                    lineHeight: "80px",
                    whiteSpace: "normal",
                    wordBreak: "break-word",
                    lineClamp: 4,
                }}
            >
                {title}
            </span>
        </div>
    );
};

@vercel/og の JSX は通常の Web ページとは違い、最終的には画像として描画されます。そのため、レスポンシブにするよりも、1200px x 630px の固定キャンバスとして考えた方が設計しやすいと思うのです。

今回は背景画像を全面に敷き、その上にカテゴリラベルと記事タイトルを配置しています。カテゴリの色は categoryColorMap で管理し、未定義のカテゴリではデフォルト色を使います。

記事タイトルは OGP の主役なので、大きく表示しています。ただし、長いタイトルを無制限に入れると崩れるため、パラメータ側で 120 文字に切り詰め、表示側では lineClamp: 4 を指定しています。

ImageResponse で PNG を返す

ルート本体の src/og/blogOgRoute.tsx では、パラメータを検証し、アセットを読み込み、ImageResponse を返します。

import { ImageResponse } from "@vercel/og";
import type { Context } from "hono";

import { BlogOgImage } from "./BlogOgImage";
import { getBlogOgAssets } from "./assets";
import { parseBlogOgParams } from "./params";

const WIDTH = 1200;
const HEIGHT = 630;

export const blogOgRoute = async (c: Context) => {
    const params = parseBlogOgParams(new URL(c.req.url));

    if (!params.ok) {
        return c.text(params.message, 400);
    }

    try {
        const assets = await getBlogOgAssets();

        return new ImageResponse(
            <BlogOgImage baseImageDataUrl={assets.baseImageDataUrl} title={params.title} category={params.category} />,
            {
                width: WIDTH,
                height: HEIGHT,
                fonts: assets.fonts,
                headers: {
                    "cache-control": "public, max-age=86400",
                },
            },
        );
    } catch (error) {
        console.error(error);
        return c.text("Failed to generate OGP image", 500);
    }
};

このファイルでやっていることは少なく、処理の流れは次の 4 つだけです。

  1. URL から titlecategory を取り出す
  2. 不正なパラメータなら 400 を返す
  3. 背景画像とフォントを読み込む
  4. ImageResponse で PNG を生成して返す

ImageResponseheaders には cache-control を付けています。ただし、実際に外部からアクセスされる公開 URL では、この後に説明する Cloudflare Pages Functions 側でもキャッシュ制御を行っています。

ビルド時に必要なファイルをコピーする

開発時は Bun でそのまま起動していますが、本番では Node.js で起動するために dist/ を作っています。ビルドスクリプトでは、アプリ本体だけでなく、@vercel/og が実行時に参照するファイルもコピーしています。

#!/usr/bin/env sh
set -eu

ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT_DIR"

mkdir -p dist/src dist/assets

bun build src/node.ts --target=node --outfile=dist/src/index.js

cp -R src/assets/. dist/assets/
cp node_modules/@vercel/og/dist/Geist-Regular.ttf node_modules/@vercel/og/dist/resvg.wasm dist/src/

ここで src/assets/ をコピーしているのは、背景画像と LINE Seed JP フォントを本番環境でも読めるようにするためです。

また、@vercel/og は内部で resvg.wasm などを使うため、これらも dist/src/ にコピーしています。これを忘れると、ローカル開発では動いたのにビルド後の Node.js 実行で画像生成に失敗する、という状態になりやすいです。

本番用の起動は次の流れです。

bun run build
bun run start

自宅サーバで常駐させる場合は、ビルド済みの dist/src/index.js を systemd などから起動すればよいです。

Cloudflare Pages Functions でキャッシュする

OGP 画像生成サーバをそのまま公開 URL にしても動きますが、このブログでは本体サイト側に /og/blog という URL を用意し、Cloudflare Pages Functions で中継しています。

理由は主に 3 つあります。

  1. og:image を本体サイトと同じドメインの URL にできる
  2. Cloudflare の Cache API で生成済み画像をキャッシュできる
  3. 生成サーバが落ちたときに、本体サイト側でフォールバックできる

Functions 側では、まず titlecategoryv を正規化します。v はキャッシュバージョンです。デザインを変えたときに v=2 のように更新すれば、古いキャッシュと新しい画像を分けられます。

const OG_ORIGIN = "https://og.chimonakiko.net/og/blog";
const CACHE_VERSION = "1";
const CACHE_TTL_SECONDS = 60 * 60 * 24 * 30;
const FALLBACK_TTL_SECONDS = 60 * 5;

const normalizeParam = (value, maxLength) => (value ?? "").trim().replace(/\s+/g, " ").slice(0, maxLength);

const normalizeVersion = (value) => {
    const version = normalizeParam(value, 24);

    return /^[A-Za-z0-9._-]+$/.test(version) ? version : CACHE_VERSION;
};

キャッシュキーは、正規化した vcategorytitle から作っています。

const cacheUrl = new URL(requestUrl.origin + requestUrl.pathname);
cacheUrl.searchParams.set("v", version);
cacheUrl.searchParams.set("category", category);
cacheUrl.searchParams.set("title", title);

const cacheKey = new Request(cacheUrl.href, { method: "GET" });
const cache = caches.default;
const cachedResponse = await cache.match(cacheKey);

if (cachedResponse) {
    const headers = new Headers(cachedResponse.headers);
    headers.set("X-OG-Cache", "HIT");

    return new Response(cachedResponse.body, {
        status: cachedResponse.status,
        headers,
    });
}

キャッシュがなければ、OGP 画像生成サーバへ問い合わせます。

const originUrl = new URL(OG_ORIGIN);
originUrl.searchParams.set("category", category);
originUrl.searchParams.set("title", title);

const originResponse = await fetch(originUrl, {
    headers: {
        Accept: "image/png",
    },
});

生成サーバが PNG を返した場合は、レスポンスヘッダーを整え、30 日間キャッシュするようにしています。

const headers = new Headers(originResponse.headers);
headers.delete("Set-Cookie");
headers.set("Content-Type", "image/png");
headers.set("Cache-Control", `public, max-age=${CACHE_TTL_SECONDS}, immutable`);
headers.set("X-OG-Cache", "MISS");

const response = new Response(originResponse.body, {
    status: 200,
    headers,
});

context.waitUntil(cache.put(cacheKey, response.clone()));

return response;

context.waitUntil() を使っているので、キャッシュ保存処理をレスポンス返却の後ろに回せます。初回アクセスは MISS になりますが、次回以降は HIT になり、画像生成サーバまで到達しません。

生成サーバが落ちている場合や、PNG 以外のレスポンスが返った場合は、静的な /ogp.png を返すようにしました。

const createFallbackResponse = async (context) => {
    const fallbackUrl = new URL("/ogp.png", context.request.url);
    const fallbackResponse = await context.env.ASSETS.fetch(fallbackUrl);

    if (!fallbackResponse.ok) {
        return createTextResponse("OG image generation failed", 502);
    }

    const headers = new Headers(fallbackResponse.headers);
    headers.delete("Set-Cookie");
    headers.set("Content-Type", "image/png");
    headers.set("Cache-Control", `public, max-age=${FALLBACK_TTL_SECONDS}`);
    headers.set("X-OG-Cache", "BYPASS");
    headers.set("X-OG-Fallback", "origin-error");

    return new Response(fallbackResponse.body, {
        status: 200,
        headers,
    });
};

OGP 画像は SNS やメッセージアプリのプレビューで使われるものなので、生成サーバが落ちたからといって画像 URL 自体が 500 になるのは避けたいです。最低限のフォールバック画像を返せるようにしておくと、運用上かなり安心できます。

今のところ実家暮らしなので、親とかがサーバのコンセント抜くのが一番のリスクなんですよね……。(突然の自分語り)

まとめ

今回は、Astro 製ブログの記事ごとに OGP 画像を動的生成する仕組みを作りました。

画像生成自体は Hono と @vercel/og でかなり素直に書けます。ただ、実際に公開して運用することを考えると、画像生成処理だけでなく、キャッシュ、フォールバック、URL の安定性も同じくらい重要でした。

最終的には、OGP 画像生成サーバを別プロジェクトとして切り出し、本体サイト側の Cloudflare Pages Functions でキャッシュしながら配信する形にしました。これで記事ごとの OGP 画像を出しつつ、画像生成サーバへの負荷や障害時の影響を抑えられます。

今後は、タイトルの長さに応じたフォントサイズ調整や、カテゴリ定義の本体サイトとの共通化も検討したいです。

© 2026 Chimonakiko