React, Vue.js
投稿日:

Next.js + AI:動的OGP画像をVercel OG + AIプロンプトで自動生成する

こんにちは、株式会社ファストコーディングのフルスタックエンジニア、独身貴族Fireです。

最近、ツーリング仲間のLINEグループにブログのURLを貼ったら「サムネイル出ないけど」と言われまして。確認したらOGP画像が設定されていなかった。自分のバイクブログなら笑い話ですが、仕事で作ったサイトで同じことが起きたらシャレにならないですよね。

SNSでシェアされたときの「顔」がOGP画像です。顔がないのは名刺なしで営業に行くようなもの。

AI駆動開発の文脈で、@vercel/ogを使った動的OGP画像生成と、記事タイトルからAIにデザインパラメータ(配色・レイアウト)を提案させる仕組みを作ってみました。手動でCanvaを開いてOGP画像を作る時代は終わりです。

OGP画像、手動で作っていませんか

ブログやオウンドメディアを運用していると、OGP画像の作成は地味に手間がかかります。記事ごとにCanvaやFigmaを開いて、タイトルを流し込んで、配色を調整して、書き出して、アップロードする。1枚5分として、月20本の記事なら100分。年間だと20時間です。

@vercel/ogを使えば、この作業をコードで自動化できます。記事のタイトルやカテゴリに応じて、動的にOGP画像を生成するAPI Routeを1つ作るだけです。

@vercel/ogの仕組み

@vercel/ogは、Vercelが提供するOGP画像生成ライブラリです。Edge Runtimeで動作し、JSX/TSXでOGP画像のレイアウトを定義できます。内部的にはSatoriというライブラリを使って、JSXをSVGに変換し、さらにPNG画像に変換しています。

特徴は以下の通りです。

  • JSX/TSXでレイアウトを記述できる(HTMLライクな書き心地)
  • Edge Runtimeで動作するため、Serverless Functionsと比較して高速
  • Flexboxベースのレイアウト(CSSの一部サブセットに対応)
  • カスタムフォントの読み込みに対応

制約もあります。CSSのGridレイアウトは使えず、Flexboxのみです。また、対応しているCSSプロパティはSatoriの仕様に準拠しており、すべてのCSSが使えるわけではありません。

基本的なOGP画像生成のコード

まず、シンプルなOGP画像を生成するRoute Handlerを作ります。

// app/api/og/route.tsx
import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';

export const runtime = 'edge';

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const title = searchParams.get('title') ?? 'Default Title';

  return new ImageResponse(
    (
      <div
        style={{
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
          justifyContent: 'center',
          backgroundColor: '#1a1a2e',
          color: '#ffffff',
          padding: '40px 60px',
        }}
      >
        <div
          style={{
            fontSize: 60,
            fontWeight: 700,
            textAlign: 'center',
            lineHeight: 1.3,
          }}
        >
          {title}
        </div>
        <div
          style={{
            fontSize: 24,
            marginTop: 30,
            color: '#a0a0b0',
          }}
        >
          FASTCODING BLOG
        </div>
      </div>
    ),
    {
      width: 1200,
      height: 630,
    }
  );
}

これだけで /api/og?title=記事タイトル にアクセスすると、1200×630のOGP画像が生成されます。HTMLのmetaタグに以下を設定すれば、SNSシェア時にこの画像が表示されます。

<meta property="og:image" content="https://example.com/api/og?title=記事タイトル" />

AIにデザインパラメータを提案させる

ここからがAI駆動開発のポイントです。記事のタイトルやカテゴリに応じて、AIに配色とレイアウトを提案させます。

私がAIに渡したプロンプトはこういう構造です。

以下のブログ記事のOGP画像用デザインパラメータを提案してください。

記事タイトル:「${title}」
カテゴリ:${category}

以下のJSON形式で出力してください。
{
  "bgColor": "背景色(hex)",
  "textColor": "テキスト色(hex)",
  "accentColor": "アクセント色(hex)",
  "layout": "center" | "left-aligned" | "two-tone",
  "mood": "テーマの雰囲気(1単語)"
}

制約:
- コントラスト比はWCAG AA基準(4.5:1以上)を満たすこと
- 背景色は暗めの色が望ましい(SNSのフィードで映えるため)
- カテゴリに応じた色を選ぶ(React系は青、パフォーマンス系は緑、セキュリティ系は赤)

AIが返してきたパラメータの例を紹介します。

記事タイトルbgColortextColoraccentColorlayout
React Hooksの使い方#0f172a#f8fafc#3b82f6center
パフォーマンス最適化#042f2e#f0fdf4#22c55eleft-aligned
認証セキュリティ#1c1917#fef2f2#ef4444two-tone

このパラメータをOGP生成のRoute Handlerに渡して、動的にデザインを変える仕組みです。

デザインパラメータ対応のRoute Handler

AIの提案を受けて作った、デザインパラメータに対応したRoute Handlerのコードです。

// app/api/og/route.tsx
import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';

export const runtime = 'edge';

type OgParams = {
  title: string;
  bgColor: string;
  textColor: string;
  accentColor: string;
  layout: 'center' | 'left-aligned' | 'two-tone';
  subtitle?: string;
};

function parseParams(searchParams: URLSearchParams): OgParams {
  return {
    title: searchParams.get('title') ?? 'FASTCODING BLOG',
    bgColor: searchParams.get('bg') ?? '#1a1a2e',
    textColor: searchParams.get('color') ?? '#ffffff',
    accentColor: searchParams.get('accent') ?? '#3b82f6',
    layout: (searchParams.get('layout') as OgParams['layout']) ?? 'center',
    subtitle: searchParams.get('subtitle') ?? undefined,
  };
}

function CenterLayout({ title, bgColor, textColor, accentColor, subtitle }: OgParams) {
  return (
    <div style={{
      width: '100%', height: '100%', display: 'flex',
      flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
      backgroundColor: bgColor, color: textColor, padding: '40px 60px',
    }}>
      <div style={{ width: 80, height: 4, backgroundColor: accentColor, marginBottom: 30 }} />
      <div style={{ fontSize: 52, fontWeight: 700, textAlign: 'center', lineHeight: 1.4, maxWidth: '90%' }}>
        {title}
      </div>
      {subtitle && (
        <div style={{ fontSize: 24, marginTop: 20, color: accentColor }}>{subtitle}</div>
      )}
      <div style={{ fontSize: 20, marginTop: 40, opacity: 0.6 }}>FASTCODING BLOG</div>
    </div>
  );
}

function LeftAlignedLayout({ title, bgColor, textColor, accentColor, subtitle }: OgParams) {
  return (
    <div style={{
      width: '100%', height: '100%', display: 'flex',
      flexDirection: 'column', justifyContent: 'flex-end',
      backgroundColor: bgColor, color: textColor, padding: '60px',
    }}>
      <div style={{ width: 60, height: 4, backgroundColor: accentColor, marginBottom: 20 }} />
      <div style={{ fontSize: 48, fontWeight: 700, lineHeight: 1.4, maxWidth: '80%' }}>
        {title}
      </div>
      {subtitle && (
        <div style={{ fontSize: 22, marginTop: 16, color: accentColor }}>{subtitle}</div>
      )}
      <div style={{ fontSize: 20, marginTop: 30, opacity: 0.6 }}>FASTCODING BLOG</div>
    </div>
  );
}

function TwoToneLayout({ title, bgColor, textColor, accentColor, subtitle }: OgParams) {
  return (
    <div style={{
      width: '100%', height: '100%', display: 'flex',
      backgroundColor: bgColor, color: textColor,
    }}>
      <div style={{ width: '8px', height: '100%', backgroundColor: accentColor }} />
      <div style={{
        flex: 1, display: 'flex', flexDirection: 'column',
        justifyContent: 'center', padding: '40px 60px',
      }}>
        <div style={{ fontSize: 48, fontWeight: 700, lineHeight: 1.4 }}>{title}</div>
        {subtitle && (
          <div style={{ fontSize: 22, marginTop: 16, color: accentColor }}>{subtitle}</div>
        )}
        <div style={{ fontSize: 20, marginTop: 30, opacity: 0.6 }}>FASTCODING BLOG</div>
      </div>
    </div>
  );
}

export async function GET(request: NextRequest) {
  const params = parseParams(new URL(request.url).searchParams);
  const layouts = {
    center: CenterLayout,
    'left-aligned': LeftAlignedLayout,
    'two-tone': TwoToneLayout,
  };
  const Layout = layouts[params.layout];
  return new ImageResponse(<Layout {...params} />, { width: 1200, height: 630 });
}

URLのクエリパラメータでデザインを制御できます。

/api/og?title=React Hooksの使い方&bg=%230f172a&color=%23f8fafc&accent=%233b82f6&layout=center
/api/og?title=パフォーマンス最適化&bg=%23042f2e&color=%23f0fdf4&accent=%2322c55e&layout=left-aligned
/api/og?title=認証セキュリティ&bg=%231c1917&color=%23fef2f2&accent=%23ef4444&layout=two-tone

AIの提案で修正した点

AIが最初に提案したコードから、実際に修正した箇所を紹介します。

修正1:フォントサイズの動的調整

AIの初版はフォントサイズが固定でした。しかし、タイトルが長い場合に画像からはみ出す問題がありました。

// AIの初版:固定フォントサイズ
fontSize: 60,

// 修正後:タイトル長に応じて調整
fontSize: title.length > 30 ? 42 : title.length > 20 ? 52 : 60,

実際には記事内のコードでは52pxと48pxを使っていますが、これもタイトル長を考慮した結果です。日本語の場合、全角文字は英語より幅を取るため、やや小さめに設定するのが実用的です。

修正2:コントラスト比の検証

AIは「WCAG AA基準を満たす配色」を提案しましたが、実際に検証したところ、一部の組み合わせでコントラスト比が不足していました。具体的には、#042f2e背景に#a0a0b0のサブテキストは3.2:1で、AA基準の4.5:1を満たしていません。

これは手動で修正しました。AIはWCAG基準の数値(4.5:1)を知っていますが、実際の配色の計算結果を検証する能力は不安定です。色のコントラスト比はツール(WebAIM Contrast Checker等)で必ず確認するべきです。

修正3:Edge Runtimeの制約

AIはNode.jsのfs moduleでフォントを読み込むコードを出してきましたが、Edge Runtimeではfsは使えません。fetch APIでフォントファイルを読み込む必要があります。

// AIの初版:Edge Runtimeで動かない
import fs from 'fs';
const font = fs.readFileSync('./fonts/NotoSansJP-Bold.ttf');

// 修正後:fetch APIで読み込み
const font = await fetch(
  new URL('../../assets/NotoSansJP-Bold.ttf', import.meta.url)
).then((res) => res.arrayBuffer());

これはAIがNode.js環境とEdge Runtime環境の違いを正しく区別できていない典型的な例です。制約を明示的にプロンプトに含めることで防げますが、出力後の検証は必須です。

Next.jsのmetadataとの連携

生成したOGP画像をNext.jsのMetadata APIで設定する方法です。

// app/blog/[slug]/page.tsx
import { Metadata } from 'next';

type Props = {
  params: Promise<{ slug: string }>;
};

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  return {
    title: post.title,
    openGraph: {
      title: post.title,
      images: [
        {
          url: `/api/og?title=${encodeURIComponent(post.title)}&bg=%230f172a&color=%23f8fafc&accent=%233b82f6&layout=center`,
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      images: [`/api/og?title=${encodeURIComponent(post.title)}&bg=%230f172a&color=%23f8fafc&accent=%233b82f6&layout=center`],
    },
  };
}

Next.js 15ではparamsがPromiseになった点に注意してください。await paramsで値を取得する必要があります。

まとめ

今回は、@vercel/ogを使った動的OGP画像生成と、AIにデザインパラメータを提案させる仕組みを紹介しました。

AI駆動開発でOGP画像の自動生成に取り組む際のポイントは以下の3つです。

  1. @vercel/ogはJSXでレイアウトを定義できる。Edge Runtimeで動作し、レスポンスも高速。ただしFlexboxのみでGridは使えない
  2. AIにデザインパラメータを提案させる。カテゴリやタイトルに応じた配色・レイアウトをJSON形式で出力させ、Route Handlerに渡す
  3. AIの配色提案は必ず検証する。WCAG基準のコントラスト比をAIは知っているが、実際の計算結果は不安定。ツールで確認が必須

実際に運用してみると、OGP画像の作成にかかる時間はほぼゼロになりました。記事を公開すれば自動的にOGP画像が生成されるため、Canvaを開く必要がありません。AIがデザインの方向性を提案してくれるので、配色で悩む時間も削減できます。

株式会社ファストコーディングでは、AI駆動開発を取り入れたNext.jsの実装をサポートしています。OGP画像の自動生成やSEO最適化に興味がある方は、お問い合わせフォームからお気軽にご相談ください。