チュートリアル
Astro と Satori で OG 画像を自動生成する
· 更新 · 読了まで約15分
目次
書いた記事が X や Slack、Discord で共有されたとき、第一印象を決めるのは OG 画像です。サイト共通のバナーでも用は足りますが、記事タイトルの入ったカードなら、素通りされるリンクと開いてもらえるリンクの差が出ます。しかも記事が数本を超えたあたりから、Figma で1枚ずつ作るのは楽しい作業ではなくなります。
このサイトでは記事ごとに1枚を自動生成しています。設定は以下のとおりです。Satori が JSX の形をしたオブジェクトを SVG に変え、resvg がそれを PNG に焼き、Astro の静的エンドポイントが両方をビルド時に走らせます。出てくるのは dist/ に置かれた素の PNG ファイルだけ。サーバーレス関数も実行時コストも要りません。Cloudflare Workers の静的アセットに載っているこのサイトも含めて、静的ホスティングならどこでも動きます。
ここに載せているのは2026年7月時点、当時このサイトが使っていたセリフ体向けの実装です。その後フォントは Inter に統一し、エンドポイントも一緒に移りました(
src/pages/og/[...path].png.ts、静的な Inter 2ウェイト、文字サイズの段も短く)。構成と引っかかりどころはそのままで、いま配信されているものとの違いはフォント名と数値だけです。
この記事の英語版に対して生成されたカードは、実際にはこうなります。

ヘッドレスブラウザではなく Satori を使う理由
定番は、HTML テンプレートを Puppeteer でスクリーンショットに撮る方法です。これでも動きますが、ビルドに Chromium 一式のダウンロードを引きずり込み、1枚あたり数秒を足すことになります。Satori は 1MB ほどの純 JS レイアウトエンジンで、CSS の実用的な部分(flexbox、ボーダー、グラデーション、行数の切り詰め)をサポートし、1枚のレイアウトを数十ミリ秒で組みます。実際にコストがかかるのはそのあと、SVG をラスタライズする工程です(§5 に実測値があります)。
細かい制御は要らず、既製の見た目で十分ということなら astro-og-canvas もあります。私が直接書いているのは、テーマ作者の OG カードはサイトのタイポグラフィと揃っているべきだと思うからです。自前で書いてもコード量はほとんど変わりません。
1. インストール
pnpm add satori @resvg/resvg-js
2. フォント、ここで必ず引っかかる
Satori は描画するすべてのグリフについて埋め込みフォントのデータを要求します。そして越えられない制限が2つあります。
woff2は使えない。 対応しているのはttf・otf・woffだけです- 可変フォントは使えない。 ウェイトが固定された静的なファイルが要ります
私と同じく Fontsource を使っているなら、パッケージの中身を確認してください。@fontsource/instrument-serif は woff と woff2 の両方を配っているので問題ありません。一方 @fontsource-variable/* は可変の woff2 しか入っておらず、Satori からは使えません。
この2つは、もう1点、分割の仕方も違います。@fontsource-variable/* はサブセットではなく軸ごとに分かれていて、そうと気づくまでにレンダリングをブロックする CSS を 900ms 分踏んでいました。静的な woff は安定した場所にコピーして、ビルドが node_modules の内部構造に依存しないようにしておきます。
mkdir -p src/assets/og
cp node_modules/@fontsource/instrument-serif/files/instrument-serif-latin-400-normal.woff src/assets/og/
cp node_modules/@fontsource/instrument-serif/files/instrument-serif-latin-400-italic.woff src/assets/og/
3. エンドポイントを書く
src/pages/og/[slug].png.ts を作ります。Astro では .png.ts というファイルが静的エンドポイントになり、getStaticPaths が全記事を列挙し、GET ハンドラが返した PNG のバイト列を astro build が dist/og/<slug>.png に書き出してくれます。
これは記事を src/content/blog/ の直下に置いている前提です。ロケール別でも年別でも、サブディレクトリに分けた瞬間に content layer の post.id は en/my-post の形になり、スラッシュを含む値は [slug] のパラメータに入りません。その場合は rest パラメータ([...path].png.ts)にして、接頭辞を自分で剥がします。このサイトがいまそうしている理由もそれで、下のコードをそのまま貼ったときにビルドが落ちるとしたら、まずここです。
Satori は React の要素を受け取りますが、React も .tsx ファイルも要りません。読んでいるのは { type, props: { style, children } } という形のオブジェクトだけなので、小さなファクトリ関数があれば足ります。
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
const SITE = 'p4ni';
const TAGLINE = 'Astro themes & tutorials';
const DOMAIN = 'astro.p4ni.com';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
const fontDir = path.join(process.cwd(), 'src/assets/og');
const serifNormal = await readFile(path.join(fontDir, 'instrument-serif-latin-400-normal.woff'));
const serifItalic = await readFile(path.join(fontDir, 'instrument-serif-latin-400-italic.woff'));
// Satori が食べるのは React の形をしたオブジェクト。JSX は要らない。
function el(
type: string,
style: Record<string, unknown>,
children?: unknown
): Record<string, unknown> {
return { type, props: { style, children } };
}
export const GET: APIRoute = async ({ props }) => {
const { post } = props;
const title: string = post.data.title;
const category: string = post.data.category;
// タイトルが長いほど文字を小さくする。
const titleSize = title.length > 70 ? 56 : title.length > 45 ? 64 : 76;
const card = el(
'div',
{
width: '100%',
height: '100%',
display: 'flex',
backgroundColor: '#faf6ef',
padding: 40,
fontFamily: 'Instrument Serif',
},
[
el(
'div',
{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
border: '2px solid #c9bda6',
padding: '48px 56px',
},
[
el(
'div',
{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
width: '100%',
},
[
el('div', { fontSize: 44, color: '#211d18' }, SITE),
el(
'div',
{ fontSize: 26, color: '#d44a1a', textTransform: 'uppercase', letterSpacing: 4 },
category
),
]
),
el(
'div',
{
display: 'flex',
fontSize: titleSize,
lineHeight: 1.08,
color: '#211d18',
lineClamp: 4,
},
title
),
el(
'div',
{
display: 'flex',
justifyContent: 'space-between',
width: '100%',
borderTop: '1px solid #e2d9c8',
paddingTop: 28,
},
[
el('div', { fontSize: 28, color: '#857c6f' }, DOMAIN),
el('div', { fontSize: 28, color: '#575046', fontStyle: 'italic' }, TAGLINE),
]
),
]
),
]
);
const svg = await satori(card as never, {
width: 1200,
height: 630,
fonts: [
{ name: 'Instrument Serif', data: serifNormal, weight: 400, style: 'normal' },
{ name: 'Instrument Serif', data: serifItalic, weight: 400, style: 'italic' },
],
});
const png = new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } }).render().asPng();
return new Response(new Uint8Array(png), {
headers: { 'Content-Type': 'image/png' },
});
};
Satori ならではの注意点がいくつかあります。
- 子要素が複数ある入れ物には必ず
display: 'flex'を書く。 Satori が実装しているのは flexbox だけで、子が2つ以上あるのに display の指定が無い要素に出くわすと例外を投げます lineClamp: 4を入れると、長いタイトルがカードからはみ出さずに省略記号で切れます- 文字サイズの3段階(
76 → 64 → 56) で、短いタイトルは大きく、長いタイトルはカードに収まるようにしています。雑ですが、テキストの実寸を測るよりましです。書体を変えたら調整し直してください。Inter は同じ級数でも Instrument Serif より横に広いので、いまの版は60 → 52 → 46まで落としています - フォントはリクエストごとではなくモジュールのトップレベルで一度だけ読みます。このファイルはビルド中に Node で走るので、
node:fsを使って構いません - 上の定数は読みやすさのために直接書いています。 このサイトが動かしているファイルでは、サイト名と URL を
src/consts.tsから取り(ドメインはnew URL(SITE_URL).hostnameで導いています)、カテゴリは i18n の辞書を通してから渡しています。そのおかげでbuild-in-publicが生スラッグのままではなく「Build in Public」と表示されます
4. レイアウトに繋ぐ
記事ごとの og:image を、生成したファイルに向けます。記事ページ側はこうなります。
---
const ogImage = post.data.ogImage ?? `/og/${post.id}.png`;
---
<BaseLayout ogImage={ogImage} ogImageAlt={post.data.title} ogType="article" />
frontmatter の ogImage を上書き用に残しておくのは、たった2行でも書く価値があります。手作りの画像を用意したい記事は出てきますし、frontmatter があればそれ、無ければ生成物という優先順位はタダで作れます。記事以外のページ(トップページやタグページ)には生成カードが無いので、レイアウトにサイト共通のデフォルトも持たせておきます。
ここで解決したパスは、もう一度使い回せます。BlogPosting の image フィールドも同じものを欲しがるので、ページが出力する JSON-LD には二度目の導出をせずそのまま渡せます。
レイアウトの head では、絶対 URL に加えてサイズも出しておきます。幅と高さが宣言されていれば、クローラーは画像本体が届く前にカードの領域を確保できるからです。
<meta property="og:image" content={new URL(ogImage, Astro.site).href} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={ogImageAlt} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={new URL(ogImage, Astro.site).href} />
5. 確認する
pnpm build を実行してログを見ると、画像が1枚ずつ独立したルートとして出てきます。
├─ /og/astro-og-images-satori.png (+1.98s)
├─ /og/best-astro-directory-themes.png (+1.78s)
├─ /og/deploy-astro-to-cloudflare-workers.png (+1.85s)
まず dist/og/ の PNG を直接開いて確かめ、次にデプロイ済みの記事 URL を opengraph.xyz に貼って本物のカードを見ます。デザインを詰めている最中なら、pnpm dev が /og/<slug>.png をそのまま配信してくれるので、毎回ビルドし直さずタブを再読み込みするだけで済みます。
見込むコストは1枚あたり1.8秒ほどで、しかもこれはならされません(回数を重ねても安くなりません)。私は最初の1枚が初期化のコストを吸収して、残りはほぼタダになるだろうと踏んでいました。上の数字を見るとそうなっていません。2枚目も3枚目も1枚目と同じ値段です。記事3本のサイトなら、6.7秒のビルドのうち5.6秒がこれ。それでも OG パイプラインとしては最安ですが、記事が50本になればビルドのたびに1分半が乗ります。そのあたりから、PNG をキャッシュしてタイトルが変わったときだけ作り直す仕組みの元が取れ始めます。
引っかかりどころのまとめ
woff2と可変フォントは、Satori から見ると存在しないも同然です。変換するか、静的なwoff/ttfを使ってください- 子要素が2つ以上なら
display: 'flex'を明示します。毎回です - 対応しているのは CSS の一部だけです。
gridもposition: stickyも擬似要素もありません。凝ったデザインを考える前に Satori の README を見てください - 絵文字と日本語などの CJK には追加のフォントデータが要ります。渡したフォントの中に無いグリフは描けません
- 下書き記事は
getStaticPathsで除外してください(上のコードのとおり)。でないと公開されていない記事の画像まで作ることになります
仕組みはこれで全部です。エンドポイントのファイル1つ、依存2つ、フォント2ファイル。これから書く記事には自動でブランドの入ったカードが付きます。私が作っている Astro テーマのように数を重ねるほど効いてくる、小さな自動化のひとつです。