T-CREATOR

Astro で動的 OG 画像を生成する:Satori/Canvas 連携の実装レシピ

Astro で動的 OG 画像を生成する:Satori/Canvas 連携の実装レシピ

SNS でシェアされたときに表示される OG 画像(Open Graph Image)は、クリック率を大きく左右する重要な要素です。しかし、記事ごとに画像を手動で作成するのは手間がかかりますよね。Astro を使えば、記事のタイトルや説明文から自動的に OG 画像を生成できるんです。

本記事では、Astro で SatoriCanvas を組み合わせて、動的に OG 画像を生成する実装方法を詳しく解説します。React コンポーネントのような JSX で画像デザインを定義でき、フォントやレイアウトも自由にカスタマイズできますよ。

背景

OG 画像の重要性と課題

Open Graph 画像は、Twitter(X)や Facebook などの SNS で記事がシェアされる際に表示されるサムネイル画像のことです。魅力的な OG 画像があると、ユーザーの目に留まりやすく、クリック率が向上します。

従来の OG 画像生成には、いくつかの選択肢がありました。

#方法メリットデメリット
1手動作成デザインの自由度が高い記事ごとに作業が必要で非効率
2外部サービス簡単に導入できるカスタマイズ性が低く、コストがかかる場合も
3サーバーサイド生成自動化できるサーバー負荷が増加し、複雑な実装が必要

静的サイトジェネレーターである Astro を使う場合、ビルド時に OG 画像を生成できれば、サーバー負荷を気にすることなく、完全に自動化された画像生成が実現できます。

Satori と Canvas の役割

Satori は、Vercel が開発した HTML/CSS を画像に変換するライブラリです。JSX ライクな記法でデザインを定義でき、React コンポーネントを書くような感覚で画像を生成できるのが特徴ですね。

一方、Canvas API は、Node.js 環境でブラウザの Canvas 機能を利用できるようにするライブラリです。Satori が SVG を生成した後、それを PNG などの画像フォーマットに変換する役割を担います。

以下の図は、Astro で OG 画像が生成される基本的なフローです。

mermaidflowchart LR
  content["記事コンテンツ<br/>(タイトル・説明)"] --> satori["Satori<br/>(JSX → SVG 変換)"]
  satori --> resvg["resvg-js<br/>(SVG → PNG 変換)"]
  resvg --> output["OG 画像<br/>(PNG ファイル)"]
  astro["Astro ビルド"] --> content

図で理解できる要点:

  • Astro ビルド時に記事のメタデータを取得
  • Satori が JSX から SVG に変換
  • resvg-js(または Canvas)が PNG に変換

課題

動的 OG 画像生成における技術的課題

Astro で動的に OG 画像を生成する際、いくつかの技術的な課題があります。

エンドポイントでの画像生成

Astro では、API エンドポイント(.ts または .js ファイル)を使って動的なレスポンスを返せます。しかし、画像を生成して返すには、適切な Content-Type ヘッダーの設定やバイナリデータの処理が必要です。

日本語フォントの扱い

Satori はデフォルトでは日本語フォントを含んでいません。そのため、日本語のタイトルを含む OG 画像を生成する場合、カスタムフォントを読み込む必要があります。フォントファイルのサイズが大きいと、ビルド時間やバンドルサイズに影響を与える可能性もあるんです。

ビルド時 vs ランタイム生成

静的サイトの場合、すべてのページの OG 画像をビルド時に生成するのが理想的ですが、ページ数が多いとビルド時間が長くなる懸念があります。一方、ランタイムで生成する場合は、サーバーレス関数のコールドスタート時間やメモリ使用量を考慮する必要があります。

以下の図は、ビルド時とランタイムでの生成方式の違いを示しています。

mermaidflowchart TB
  subgraph build["ビルド時生成"]
    direction LR
    pages1["全ページ情報"] --> gen1["画像生成処理"]
    gen1 --> static1["静的ファイル<br/>(public/)"]
  end

  subgraph runtime["ランタイム生成"]
    direction LR
    request["リクエスト"] --> endpoint["API エンドポイント"]
    endpoint --> gen2["画像生成処理"]
    gen2 --> response["画像レスポンス"]
  end

  build -->|メリット:高速配信| merit1["CDN 配信可能"]
  build -->|デメリット:ビルド時間| demerit1["大量ページで時間増"]

  runtime -->|メリット:柔軟性| merit2["動的パラメータ対応"]
  runtime -->|デメリット:レイテンシ| demerit2["毎回生成のコスト"]

図で理解できる要点:

  • ビルド時生成は CDN で高速配信できるが、ページ数が多いと時間がかかる
  • ランタイム生成は柔軟だが、リクエストごとに生成コストが発生
  • プロジェクトの規模に応じて最適な方式を選択する

解決策

Satori と resvg-js による画像生成アーキテクチャ

Astro で OG 画像を動的生成するには、Satoriresvg-js を組み合わせるのが最も効率的です。resvg-js は、Rust で書かれた高速な SVG レンダリングエンジンで、Node.js から利用できます。

実装の基本的な流れは以下のとおりです。

  1. パッケージのインストール:必要なライブラリを追加
  2. フォントの準備:日本語対応のフォントファイルを用意
  3. API エンドポイントの作成:画像生成ロジックを実装
  4. JSX テンプレートの定義:OG 画像のデザインを記述
  5. メタタグの設定:生成した画像を OG タグで参照

この構成により、記事のタイトルや説明文をパラメータとして受け取り、自動的に美しい OG 画像を生成できるようになります。

パッケージのインストール

まず、必要なパッケージをインストールしましょう。Satori と resvg-js の 2 つが主要なライブラリです。

bashyarn add satori @resvg/resvg-js

これらのパッケージの役割は以下のとおりです。

#パッケージ役割バージョン例
1satoriHTML/CSS を SVG に変換^0.10.0
2@resvg/resvg-jsSVG を PNG に変換(Rust ベース)^2.6.0

resvg-js は、従来の Canvas API よりも高速で、依存関係も少ないため、特に推奨されますよ。

日本語フォントの準備

日本語のタイトルを表示するには、カスタムフォントが必要です。Google Fonts から Noto Sans Japanese をダウンロードして、プロジェクトに配置します。

bash# public/fonts ディレクトリを作成
mkdir -p public/fonts

# Noto Sans JP をダウンロード(例)
# Google Fonts から手動でダウンロードするか、以下のコマンドを実行

フォントファイルは public​/​fonts​/​NotoSansJP-Bold.ttf のような形で配置するのがおすすめです。これにより、ビルド後も静的アセットとしてアクセスできます。

具体例

API エンドポイントの実装

Astro では、src​/​pages​/​ ディレクトリに .ts ファイルを配置することで API エンドポイントを作成できます。OG 画像生成用のエンドポイントを作成しましょう。

ファイル構成

typescript// src/pages/og-image.png.ts

このファイルは ​/​og-image.png というパスでアクセスできるようになります。クエリパラメータで titledescription を受け取る形にします。

必要なインポート

まず、必要なライブラリをインポートします。

typescriptimport type { APIRoute } from 'astro';
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
  • APIRoute は Astro の API エンドポイント型定義です
  • satori が JSX を SVG に変換します
  • Resvg が SVG を PNG に変換する役割を担います
  • Node.js の fspath モジュールでフォントファイルを読み込みます

フォント読み込み関数

フォントファイルを読み込む関数を定義します。この関数はビルド時に一度だけ実行されるように、エンドポイント外で定義するのがポイントです。

typescript/**
 * 日本語フォントを読み込む関数
 * public/fonts ディレクトリから Noto Sans JP を取得
 */
async function loadFont() {
  const fontPath = join(
    process.cwd(),
    'public',
    'fonts',
    'NotoSansJP-Bold.ttf'
  );
  const fontData = await readFile(fontPath);
  return fontData;
}

process.cwd() でプロジェクトのルートディレクトリを取得し、そこからフォントファイルのパスを構築しています。

OG 画像テンプレートの定義

次に、OG 画像のデザインを JSX で定義します。Satori は React コンポーネントのような記法をサポートしていますが、すべての CSS プロパティが利用できるわけではない点に注意しましょう。

typescript/**
 * OG 画像のテンプレート
 * Flexbox を使ったレイアウトで、タイトルと説明文を配置
 */
function OGImageTemplate(
  title: string,
  description: string
) {
  return {
    type: 'div',
    props: {
      style: {
        width: '1200px',
        height: '630px',
        display: 'flex',
        flexDirection: 'column',
        justifyContent: 'center',
        alignItems: 'center',
        backgroundColor: '#1a1a2e',
        fontFamily: 'Noto Sans JP',
        padding: '60px',
      },
      children: [
        {
          type: 'div',
          props: {
            style: {
              fontSize: '64px',
              fontWeight: 'bold',
              color: '#ffffff',
              marginBottom: '32px',
              textAlign: 'center',
              lineHeight: '1.3',
            },
            children: title,
          },
        },
        {
          type: 'div',
          props: {
            style: {
              fontSize: '32px',
              color: '#a0a0a0',
              textAlign: 'center',
              lineHeight: '1.5',
            },
            children: description,
          },
        },
      ],
    },
  };
}

OG 画像の推奨サイズは 1200×630 ピクセル です。背景色は濃いめの色(#1a1a2e)を使い、テキストは白(#ffffff)で視認性を高めています。

GET ハンドラーの実装(パラメータ取得)

API エンドポイントのメイン処理を実装します。まず、クエリパラメータから titledescription を取得する部分です。

typescriptexport const GET: APIRoute = async ({ url }) => {
  // クエリパラメータから title と description を取得
  const title = url.searchParams.get('title') || 'タイトルなし';
  const description = url.searchParams.get('description') || '説明なし';

url.searchParams.get() で安全にパラメータを取得し、未指定の場合はデフォルト値を使用します。

SVG 生成処理

Satori を使って JSX テンプレートから SVG を生成します。

typescript// フォントデータを読み込み
const fontData = await loadFont();

// Satori で JSX を SVG に変換
const svg = await satori(
  OGImageTemplate(title, description),
  {
    width: 1200,
    height: 630,
    fonts: [
      {
        name: 'Noto Sans JP',
        data: fontData,
        weight: 700,
        style: 'normal',
      },
    ],
  }
);

fonts オプションで読み込んだフォントデータを指定しています。weight: 700 は太字(Bold)を意味しますよ。

PNG 変換とレスポンス返却

生成された SVG を PNG に変換し、HTTP レスポンスとして返します。

typescript  // resvg で SVG を PNG に変換
  const resvg = new Resvg(svg, {
    fitTo: {
      mode: 'width',
      value: 1200,
    },
  });

  const pngData = resvg.render();
  const pngBuffer = pngData.asPng();

  // PNG 画像をレスポンスとして返却
  return new Response(pngBuffer, {
    headers: {
      'Content-Type': 'image/png',
      'Cache-Control': 'public, max-age=31536000, immutable',
    },
  });
};

Cache-Control ヘッダーで長期キャッシュを設定することで、同じパラメータでのリクエストを効率化できます。

以下の図は、API エンドポイントでの処理フローです。

mermaidsequenceDiagram
  participant Client as ブラウザ
  participant API as /og-image.png
  participant Satori as Satori
  participant Resvg as resvg-js
  participant FS as ファイルシステム

  Client->>API: GET /og-image.png?title=記事タイトル
  API->>FS: フォントファイル読み込み
  FS-->>API: フォントデータ
  API->>Satori: JSX テンプレート + フォント
  Satori-->>API: SVG データ
  API->>Resvg: SVG → PNG 変換
  Resvg-->>API: PNG バイナリ
  API-->>Client: PNG 画像 (Content-Type: image/png)

図で理解できる要点:

  • クライアントからのリクエストでタイトルを受け取る
  • フォントを読み込み、Satori で SVG を生成
  • resvg-js で PNG に変換してレスポンス

Astro ページでの OG タグ設定

生成した OG 画像を実際のページで使用するには、<meta> タグを設定する必要があります。Astro コンポーネントで実装してみましょう。

レイアウトコンポーネントでの実装

astro---
// src/layouts/BaseLayout.astro
interface Props {
  title: string;
  description: string;
}

const { title, description } = Astro.props;

// OG 画像の URL を構築
const ogImageUrl = new URL('/og-image.png', Astro.url);
ogImageUrl.searchParams.set('title', title);
ogImageUrl.searchParams.set('description', description);
---

Astro.url を使うことで、現在のページの完全な URL を取得でき、OG 画像の絶対 URL を正しく構築できます。

メタタグの配置

生成した URL を使って、Open Graph と Twitter Card のメタタグを設定します。

astro<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>{title}</title>
    <meta name="description" content={description} />

    <!-- Open Graph タグ -->
    <meta property="og:title" content={title} />
    <meta property="og:description" content={description} />
    <meta property="og:image" content={ogImageUrl.toString()} />
    <meta property="og:type" content="article" />

    <!-- Twitter Card タグ -->
    <meta name="twitter:card" content="summary_large_image" />
    <meta name="twitter:title" content={title} />
    <meta name="twitter:description" content={description} />
    <meta name="twitter:image" content={ogImageUrl.toString()} />
  </head>
  <body>
    <slot />
  </body>
</html>

summary_large_image を指定することで、Twitter(X)で大きなカード形式で表示されるようになりますよ。

ブログ記事での使用例

実際のブログ記事ページで、このレイアウトを使用する例です。

astro---
// src/pages/blog/my-article.astro
import BaseLayout from '../../layouts/BaseLayout.astro';

const title = 'Astro で動的 OG 画像を生成する方法';
const description = 'Satori と Canvas を使った実装レシピを紹介します';
---

<BaseLayout title={title} description={description}>
  <article>
    <h1>{title}</h1>
    <p>{description}</p>
    <!-- 記事本文 -->
  </article>
</BaseLayout>

このように記述するだけで、自動的に記事タイトルに合わせた OG 画像が生成され、SNS でのシェア時に表示されます。

カスタマイズとスタイリング

基本的な実装ができたら、デザインをさらにカスタマイズしてみましょう。

グラデーション背景の追加

背景に美しいグラデーションを追加すると、より洗練された印象になります。

typescript// OG 画像テンプレートのスタイル部分を変更
{
  type: 'div',
  props: {
    style: {
      width: '1200px',
      height: '630px',
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'center',
      alignItems: 'center',
      background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
      fontFamily: 'Noto Sans JP',
      padding: '60px',
    },
    // ...
  },
}

ただし、Satori では CSS の linear-gradient が完全にはサポートされていないため、代わりに複数の div を重ねて疑似的にグラデーション効果を作る方法もあります。

ロゴやアイコンの追加

サイトのブランディングを強化するために、ロゴ画像を追加することもできます。

typescript// 画像を Base64 エンコードして埋め込む
{
  type: 'img',
  props: {
    src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...',
    style: {
      width: '80px',
      height: '80px',
      position: 'absolute',
      top: '40px',
      left: '40px',
    },
  },
}

画像は Base64 エンコードして data: URI として埋め込むか、外部 URL を指定できます。ただし、外部 URL の場合はビルド時にアクセスできる必要があることに注意しましょう。

パフォーマンス最適化

OG 画像生成のパフォーマンスを最適化するためのテクニックをいくつか紹介します。

フォントのキャッシング

フォントファイルの読み込みは比較的コストが高い処理なので、一度読み込んだらキャッシュするのが効果的です。

typescript// モジュールレベルでキャッシュ
let fontCache: Buffer | null = null;

async function loadFont() {
  if (fontCache) {
    return fontCache;
  }

  const fontPath = join(
    process.cwd(),
    'public',
    'fonts',
    'NotoSansJP-Bold.ttf'
  );
  fontCache = await readFile(fontPath);
  return fontCache;
}

この実装により、2 回目以降のリクエストではファイル I/O をスキップできるため、レスポンス時間が大幅に改善されます。

ビルド時の静的生成

ページ数が限られている場合は、ビルド時にすべての OG 画像を事前生成する方法も検討できます。

typescript// src/pages/og/[slug].png.ts
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';

// すべてのブログ記事のスラッグを取得
export async function getStaticPaths() {
  const posts = await getCollection('blog');

  return posts.map((post) => ({
    params: { slug: post.slug },
    props: {
      title: post.data.title,
      description: post.data.description,
    },
  }));
}

export const GET: APIRoute = async ({ props }) => {
  const { title, description } = props;
  // 画像生成処理(前述と同じ)
  // ...
};

この方法では、​/​og​/​my-article.png のような静的な URL で OG 画像にアクセスでき、CDN によるキャッシングも活用できますよ。

エラーハンドリングの追加

本番環境では、適切なエラーハンドリングが重要です。

typescriptexport const GET: APIRoute = async ({ url }) => {
  try {
    const title =
      url.searchParams.get('title') || 'タイトルなし';
    const description =
      url.searchParams.get('description') || '説明なし';

    // 画像生成処理
    // ...

    return new Response(pngBuffer, {
      headers: {
        'Content-Type': 'image/png',
        'Cache-Control':
          'public, max-age=31536000, immutable',
      },
    });
  } catch (error) {
    console.error('OG 画像生成エラー:', error);

    // エラー時はデフォルト画像を返す、または 500 エラー
    return new Response('Internal Server Error', {
      status: 500,
      headers: {
        'Content-Type': 'text/plain',
      },
    });
  }
};

エラーが発生した場合でも、ユーザーに適切なレスポンスを返すことで、サイト全体の信頼性が向上します。

動作確認とデバッグ

実装が完了したら、正しく動作するか確認しましょう。

ローカル開発サーバーでの確認

bash# 開発サーバーを起動
yarn dev

ブラウザで http:​/​​/​localhost:4321​/​og-image.png?title=テスト&description=説明文 にアクセスして、画像が正しく生成されるか確認します。

SNS シミュレーターでの検証

実際に SNS でどのように表示されるかは、以下のツールで確認できます。

#ツールURL用途
1Twitter Card Validatorhttps://cards-dev.twitter.com/validatorTwitter での表示確認
2Facebook Sharing Debuggerhttps://developers.facebook.com/tools/debug/Facebook での表示確認
3LinkedIn Post Inspectorhttps://www.linkedin.com/post-inspector/LinkedIn での表示確認

これらのツールを使えば、実際にシェアする前に OG 画像が正しく表示されるか確認できますよ。

よくあるエラーと解決方法

実装中によく遭遇するエラーとその解決方法をまとめます。

エラー 1: Error: Cannot find module '@resvg​/​resvg-js'

bash# 解決方法:パッケージを再インストール
yarn install

このエラーは、依存関係が正しくインストールされていない場合に発生します。node_modules を削除して再インストールすると解決することが多いです。

エラー 2: TypeError: Cannot read property 'toString' of null

typescript// 発生条件:フォントファイルが見つからない
// 解決方法:フォントパスを確認
const fontPath = join(
  process.cwd(),
  'public',
  'fonts',
  'NotoSansJP-Bold.ttf'
);
console.log('フォントパス:', fontPath); // デバッグ用

// ファイルの存在確認
import { access } from 'node:fs/promises';
try {
  await access(fontPath);
} catch {
  throw new Error(
    `フォントファイルが見つかりません: ${fontPath}`
  );
}

フォントファイルのパスが間違っている場合に発生します。console.log でパスを確認し、ファイルが正しい場所に配置されているか確認しましょう。

エラー 3: 日本語が表示されない(□□□ になる)

この問題は、フォントが正しく読み込まれていない場合に発生します。以下を確認してください。

  1. フォントファイルが存在するか
  2. fonts オプションで name が JSX の fontFamily と一致しているか
  3. フォントファイルが破損していないか
typescript// 正しい設定例
fonts: [
  {
    name: 'Noto Sans JP',  // ← この名前と
    data: fontData,
    weight: 700,
    style: 'normal',
  },
],

// JSX 側
style: {
  fontFamily: 'Noto Sans JP',  // ← これが一致している必要がある
}

まとめ

Astro で Satori と resvg-js を組み合わせることで、動的な OG 画像生成を簡単に実装できました。主なポイントをおさらいしましょう。

  1. Satori を使えば、JSX ライクな記法で画像デザインを定義できます
  2. resvg-js による PNG 変換は高速で、依存関係も少なく効率的です
  3. API エンドポイントとして実装することで、柔軟にパラメータを受け取れます
  4. 日本語フォントの読み込みとキャッシングでパフォーマンスを最適化できます
  5. ビルド時生成ランタイム生成を使い分けることで、プロジェクトに最適な構成を選択できます

この実装により、記事を公開するたびに手動で OG 画像を作成する手間がなくなり、一貫性のあるデザインで SNS でのシェアを促進できるようになります。Astro の静的サイト生成の強みを活かしつつ、動的な画像生成も実現できるのは素晴らしいですね。

ぜひ、あなたのプロジェクトでも試してみてください。カスタマイズの幅も広いので、ブランドに合わせたオリジナルのデザインを作り込んでいくのも楽しいですよ。

関連リンク