Web開発をしていると、HTMLとして表示するだけでなく、その内容を画像として出したくなることがある。

ブログ記事ごとのOGP画像、SNSへ投稿するカード、ユーザー名やスコアを入れたシェア画像、プロフィールカードなどが分かりやすい例だ。

こうした画像はデザインツールで1枚ずつ作ることもできるが、タイトルやユーザー情報が変わるたびに手作業では追いつかない。Webアプリが持っているデータから画像を組み立てられれば、更新に合わせて自動生成できる。

React + TypeScriptでは、画像のレイアウトをTSXで書き、SatoriでSVGへ変換できる。SVG生成まではクライアントサイドとサーバーサイドで共通だが、最終画像への変換方法は異なる。クライアントサイドではCanvas、サーバーサイドではSharpを使う。

クライアントとサーバーで分かれる画像生成フロー

SatoriはブラウザでもNode.jsでも使える。一方でSharpはブラウザをサポートしていない。Satoriまでは共通で、SVG生成後にクライアントはCanvas、サーバーはSharpへ分かれるのが、この構成の基本になる。

クライアントサイドとサーバーサイドでは要件が違う

同じ「画像を生成したい」でも、OGP画像とブラウザ上の画像メーカーでは必要な性質が違う。

用途生成する側理由
OGP画像サーバーSNSクローラーから取得できる公開URLが必要
記事サムネイルサーバー同じ入力なら同じ画像を返し、CDNでキャッシュしやすい
SNSカード作成ツールクライアントユーザーの入力をその場で画像化できる
プロフィールカードの保存クライアントサーバーへ画像生成を依頼しなくても端末内で完結できる
編集画面のプレビュークライアントSVGのまま表示すれば高速に確認できる
公開画像と編集プレビュークライアント + サーバーレイアウトを共有し、プレビューと公開画像の生成を分けられる

OGP画像はサーバーサイドで生成するのが基本になる。

HTMLに次のようなメタタグがあるとする。

<meta
  property="og:image"
  content="https://example.com/og/articles/react-image-generation.png"
/>

SNSのクローラーは、このURLへ直接アクセスして画像を取得する。ユーザーのブラウザでJavaScriptを実行してから作られるblob: URLは、クローラーから参照できない。

一方、ブラウザ上でタイトルや背景色を変更して、その場でPNGを保存するツールなら、クライアントサイドだけで画像生成を完結できる。画像生成のためだけにサーバーへリクエストする必要はない。

生成場所を決める基準は、画像を取得する相手と、生成した画像を公開URLとして残す必要があるかどうかだ。

React + TypeScriptではSatoriを共通部分にできる

SatoriはJSXからSVGを生成するライブラリだ。

通常のReactでは、JSXからReact DOMを通してブラウザのDOMを描画する。Satoriを使う場合は、出力先がDOMではなくSVGになる。

React DOMとSatoriの出力先の違い

例えば次のようなコンポーネントを画像レイアウトとして使える。

interface ShareCardProps {
  title: string;
  category: string;
}

export function ShareCard({ title, category }: ShareCardProps) {
  return (
    <div
      lang="ja-JP"
      style={{
        width: "100%",
        height: "100%",
        display: "flex",
        flexDirection: "column",
        justifyContent: "space-between",
        padding: "72px",
        backgroundColor: "#111827",
        color: "#ffffff",
        fontFamily: "Noto Sans JP",
      }}
    >
      <div
        style={{
          display: "flex",
          fontSize: "28px",
        }}
      >
        {category}
      </div>

      <div
        style={{
          display: "flex",
          fontSize: "64px",
          fontWeight: 700,
          lineHeight: 1.35,
        }}
      >
        {title}
      </div>

      <div
        style={{
          display: "flex",
          fontSize: "24px",
        }}
      >
        example.com
      </div>
    </div>
  );
}

見た目はReactコンポーネントだが、ブラウザへ直接レンダーするためのコンポーネントとは少し性格が違う。

Satoriが受け取るJSXはpureかつstatelessである必要があり、useStateuseEffectdangerouslySetInnerHTMLはサポートされていない。画像に必要なデータはSatoriを呼ぶ前に取得し、propsとしてShareCardへ渡す。

画像生成コンポーネントへデータを渡す流れ

SatoriはブラウザのCSSをそのまま再現するものではない

SatoriはHTMLとCSSに近い書き方を使えるが、ブラウザのレンダリングエンジンそのものではない。レイアウトにはYogaが使われており、利用できるCSSにも範囲がある。

Flexbox、padding、margin、background、border、fontなど画像レイアウトでよく使う機能には対応しているが、既存WebページのCSSをそのまま移して同じ見た目になるとは限らない。

Webページ用コンポーネントにはブラウザ向けCSS、インタラクション、状態管理が入りやすい。画像用コンポーネントはpropsとSatoriが対応するCSSだけで完結させた方がよい。

「Reactのコンポーネントだから既存UIをそのまま画像にできる」と考えると、CSS差分やHookの制約で詰まりやすい。

既存のWebページを見た目ごと画像化したい場合は、SatoriではなくPlaywrightやPuppeteerでChromiumを起動し、スクリーンショットを取る方法の方が向いていることもある。

日本語ではフォントを先に考える

Satoriで文字を描画するにはフォントデータを渡す必要がある。

2026年9月時点で対応している形式はTTF、OTF、WOFFで、WOFF2はサポートされていない。日本語を出すなら、日本語グリフを含むフォントを用意する。

const svg = await satori(<ShareCard title={title} category={category} />, {
  width: 1200,
  height: 630,
  fonts: [
    {
      name: "Noto Sans JP",
      data: regularFont,
      weight: 400,
      style: "normal",
    },
    {
      name: "Noto Sans JP",
      data: boldFont,
      weight: 700,
      style: "normal",
    },
  ],
});

fontWeight: 700を使うなら、700として使うフォントも登録しておく。

またSatoriはデフォルトで文字をSVGの<text>ではなく<path>として埋め込む。Satoriの後段でCanvasやSharpがSVGを描画するときに、同じフォントをもう一度探さなくてよい。

SatoriでフォントをSVGパスへ変換する流れ

Satoriが文字をSVG pathへ変換するため、クライアントのCanvasでもサーバーのSharpでも、同じフォントを別途インストールせずにSVGを描画できる。

2026年時点の技術スタック

React 19 + TypeScriptを前提に、画像生成には次の組み合わせを使う。

2026年9月9日時点のSatoriは0.33.4、Sharpは0.35.4だ。Reactは19系を前提にする。

クライアントとサーバーの技術スタック

クライアントサイド

クライアントサイドではSatoriをブラウザで実行し、PNG化にはブラウザ標準のCanvas APIを使える。

SVGのラスタライズ結果をブラウザの描画実装から切り離したい場合は、@resvg/resvg-wasmも候補になる。

SNSカードやダウンロード用画像のような固定レイアウトをブラウザで生成するだけなら、まずCanvasで十分かを確認する。Canvasで要件を満たせるなら、最初からWASM依存を追加する必要はない。

サーバーサイド

SharpはNode.js系の画像処理で広く使われており、SVGを入力してPNG、WebP、AVIFなどへ変換できる。

Sharp 0.35系をNode.jsで使う場合は20.9.0以上が必要だ。WebAssembly版もあるが、Sharp公式はWebブラウザでの利用をサポートしていない。

クライアントサイドのバンドルへsharpを含めず、Sharpはサーバーサイドだけで使う。

クライアントサイドではSVGまでをSatoriに任せる

クライアントサイドでは、フォントをfetch()で読み込んでSatoriへ渡す。

以下のコードは構成を示すサンプルで、この記事の環境では実行確認していない。

import satori from "satori";

import { ShareCard } from "./ShareCard";

const regularFontPromise = fetch("/fonts/NotoSansJP-Regular.ttf").then(
  async (response) => {
    if (!response.ok) {
      throw new Error("Failed to load regular font");
    }

    return response.arrayBuffer();
  },
);

const boldFontPromise = fetch("/fonts/NotoSansJP-Bold.ttf").then(
  async (response) => {
    if (!response.ok) {
      throw new Error("Failed to load bold font");
    }

    return response.arrayBuffer();
  },
);

export async function createCardSvg(
  title: string,
  category: string,
): Promise<string> {
  const [regularFont, boldFont] = await Promise.all([
    regularFontPromise,
    boldFontPromise,
  ]);

  return satori(<ShareCard title={title} category={category} />, {
    width: 1200,
    height: 630,
    fonts: [
      {
        name: "Noto Sans JP",
        data: regularFont,
        weight: 400,
        style: "normal",
      },
      {
        name: "Noto Sans JP",
        data: boldFont,
        weight: 700,
        style: "normal",
      },
    ],
  });
}

フォントは毎回fetchする必要がないので、Promiseをmodule scopeへ置いて使い回している。

Satoriが返すのはPNGではなくSVG文字列だ。

<svg ...>
  ...
</svg>

編集画面でSVGを表示するだけなら、PNGへ変換せずにSatoriの出力をそのままプレビューへ使える。

const svg = await createCardSvg("Reactで画像生成", "Frontend");

const blob = new Blob([svg], {
  type: "image/svg+xml;charset=utf-8",
});

const url = URL.createObjectURL(blob);

URL.createObjectURL()で作ったURLをimgへ渡せば、PNGへ変換せずに表示できる。

<img src={url} alt="生成したカードのプレビュー" />

編集画面でタイトルを変えながら確認するだけなら、毎回PNGへラスタライズする必要はない。

ブラウザでPNGが必要ならCanvasへ描画する

ユーザーがPNGとして保存する場合は、SVGをCanvasへ描画し、toBlob()で画像データを取り出せる。

export async function svgToPng(
  svg: string,
  width: number,
  height: number,
): Promise<Blob> {
  const svgBlob = new Blob([svg], {
    type: "image/svg+xml;charset=utf-8",
  });

  const svgUrl = URL.createObjectURL(svgBlob);

  try {
    const image = new Image();
    image.src = svgUrl;
    await image.decode();

    const canvas = document.createElement("canvas");
    canvas.width = width;
    canvas.height = height;

    const context = canvas.getContext("2d");

    if (!context) {
      throw new Error("Canvas 2D context is unavailable");
    }

    context.drawImage(image, 0, 0, width, height);

    return await new Promise<Blob>((resolve, reject) => {
      canvas.toBlob((blob) => {
        if (blob) {
          resolve(blob);
          return;
        }

        reject(new Error("Failed to create PNG"));
      }, "image/png");
    });
  } finally {
    URL.revokeObjectURL(svgUrl);
  }
}

HTMLCanvasElement.toBlob()はPNGを標準で扱える。JPEGやWebPもブラウザが対応していれば指定できる。圧縮や複数形式への変換まで必要なら、クライアントサイドではresvg-wasmなどの追加手段、サーバーサイドではSharpを検討する。

生成したBlobは、プレビュー表示にもダウンロードにも使える。

const png = await svgToPng(svg, 1200, 630);
const pngUrl = URL.createObjectURL(png);

const anchor = document.createElement("a");
anchor.href = pngUrl;
anchor.download = "share-card.png";
anchor.click();

URL.revokeObjectURL(pngUrl);

SatoriとCanvasをブラウザ内で実行する構成なら、画像生成APIは不要だ。

クライアントサイドではCORSと外部画像に注意する

クライアント生成で詰まりやすいのが外部リソースだ。

例えばユーザーのアバターを別ドメインから取得する場合、ブラウザのfetch()にはCORSが適用される。

クライアント画像生成とCORS

取得元がCORSを許可していなければ、クライアントサイドのfetch()では外部画像やフォントを取得できない。

また、外部リソースを参照したSVGをCanvasへ描画すると、後からCanvasの内容を取り出せないケースもある。画像生成に使う外部画像は、事前に取得してData URLへ変換するなど、SVGの中へ自己完結させておく方がトラブルを減らしやすい。

フォントも同様で、別ドメインから読むならCORS設定が必要になる。自分のアプリで使う固定フォントなら、同一オリジンから配信する構成が単純だ。

サーバーサイドではSatoriのSVGをSharpへ渡す

サーバーサイドでは、Satoriで生成したSVGをSharpへ渡してPNGなどへ変換する。

サーバーサイドの画像生成フロー

Node.jsならフォントをファイルから読み込める。

以下もサーバーサイドの構成を示すサンプルで、この記事の環境では実行確認していない。

import { readFile } from "node:fs/promises";
import satori from "satori";
import sharp from "sharp";

import { ShareCard } from "./ShareCard.js";

const regularFontPromise = readFile(
  new URL("../assets/NotoSansJP-Regular.ttf", import.meta.url),
);

const boldFontPromise = readFile(
  new URL("../assets/NotoSansJP-Bold.ttf", import.meta.url),
);

export async function createCardPng(
  title: string,
  category: string,
): Promise<Buffer> {
  const [regularFont, boldFont] = await Promise.all([
    regularFontPromise,
    boldFontPromise,
  ]);

  const svg = await satori(
    <ShareCard title={title} category={category} />,
    {
      width: 1200,
      height: 630,
      fonts: [
        {
          name: "Noto Sans JP",
          data: regularFont,
          weight: 400,
          style: "normal",
        },
        {
          name: "Noto Sans JP",
          data: boldFont,
          weight: 700,
          style: "normal",
        },
      ],
    },
  );

  return sharp(Buffer.from(svg)).png().toBuffer();
}

サーバー側ではSatoriがSVGを生成し、SharpがSVGをラスタライズしてPNGなどへ変換する。

const svg = await satori(element, options);
const png = await sharp(Buffer.from(svg)).png().toBuffer();

Sharpを使えばWebPやAVIFにも変えられる。

const webp = await sharp(Buffer.from(svg))
  .webp({ quality: 90 })
  .toBuffer();

OGPとして返すならHTTPレスポンスとキャッシュまで考える

OGP画像を動的に返す場合は、生成したBufferをHTTPレスポンスへ載せる。

Web標準のResponseを使えるNode.js環境なら、次のように返せる。

const png = await createCardPng(article.title, article.category);

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

APIルートの書き方はNext.js、Astro、Expressなどで変わるが、Satori + SharpでBufferを生成する部分は変わらない。

OGP画像は記事タイトルやカテゴリが変わらない限り何度生成しても同じ結果になる。アクセスのたびにSatoriとSharpを実行する必要はない。生成済み画像はCDNやアプリケーション側でキャッシュできる。

OGP画像のキャッシュフロー

ファイル名やURLへコンテンツハッシュを含められるなら、生成内容が変わったときだけURLを変えられるため、長期キャッシュを設定しやすい。

サーバーで外部URLを取るならCORSではなくSSRFを見る

クライアントサイドではCORSが問題になるが、サーバーサイドではSSRFを考える必要がある。

例えば、ユーザー入力のURLから画像を取得してカードへ埋め込む実装を考える。

const response = await fetch(userProvidedImageUrl);

サーバーからのfetch()にはブラウザのCORS制約がない。ユーザー入力のURLを無条件に取得すると、内部ネットワークやメタデータエンドポイントなど、本来アクセスさせるべきでない場所へリクエストされる可能性がある。

画像URLをユーザーが指定できるなら、ホストの許可リスト、URLの検証、専用の画像プロキシなどを検討する。

外部画像取得時のSSRF対策

外部画像の取得をサーバーへ移すとCORSの制約は外れるが、代わりにSSRF対策が必要になる。

ShareCardをクライアントとサーバーで共有する

画像レイアウトをpureなコンポーネントにすれば、同じShareCardをクライアントサイドとサーバーサイドの両方から呼べる。

画像コンポーネントを共有する構成

ただし、共有するShareCardの中にクライアントまたはサーバー固有の処理を入れない。

避けたいのは次のような処理だ。

  • window
  • document
  • localStorage
  • fs
  • processに依存する処理
  • useState / useEffect
  • API fetchそのもの

画像へ渡すデータをShareCardの外側で用意し、コンポーネントはpropsから見た目を作るだけにする。

<ShareCard
  title={article.title}
  category={article.category}
/>

データ取得をShareCardの外へ出しておけば、テストでは同じpropsから生成したSVGを比較できる。

プレビューはクライアント、公開画像はサーバーに分けられる

画像編集UIがある場合、編集画面のプレビューと公開画像を同じ側で生成する必要はない。

プレビューと公開画像を分ける構成

編集中はクライアントサイドでSVGをそのまま表示し、保存や公開のタイミングでサーバー側のSharpを使ってPNGへ変換する。公開URLへ保存すれば、SNSクローラーからも取得できる。

ShareCardを共通化しておけば、編集画面のプレビューと公開画像で同じレイアウトを使える。

Satoriを使わない方がよい場合もある

SatoriはOGPやSNSカードのように、比較的決まったレイアウトをデータから組み立てる用途に向いている。

一方、次のような要件ではブラウザを使ったスクリーンショットの方が自然な場合がある。

  • 既存ページをそのまま画像にしたい
  • 複雑なCSSをそのまま使いたい
  • Webフォントやブラウザ固有の描画結果まで合わせたい
  • JavaScript実行後のDOMを撮影したい

既存ページそのものを画像化する要件では、PlaywrightやPuppeteerでページを描画し、Screenshot APIで画像化する方法がある。

OGPのような固定レイアウトを生成するためだけにChromiumを起動したくないなら、Satori + SharpでSVGから画像を作る方が構成を小さくできる。

SVGとして組み立てれば足りるならSatori、ブラウザの描画結果そのものが必要ならPlaywrightやPuppeteer、という分け方になる。

まとめ

React + TypeScriptで動的画像を生成する場合、Satoriを使えばクライアントとサーバーで画像レイアウトを共有できる。

Satoriが担当するのはTSXからSVGを作るところまでだ。ブラウザではCanvas、サーバーではSharpを使って最終画像へ変換する。OGPは公開URLから取得できる必要があるためサーバー側、ユーザーがその場で作って保存する画像はクライアント側、という使い分けになる。

既存ページそのものを画像化したい場合はSatoriではなく、PlaywrightやPuppeteerでブラウザを描画する方法もある。