next/og で日本語のOG画像を自動生成するときに詰まる2箇所——フォントの同梱と、Vercelでの明示バンドル
Next.js の next/og(ImageResponse)で日本語のOG画像を自動生成するとき、2箇所で詰まる。日本語が豆腐になるのはフォントを渡していないから(システムフォントは使えない)。ローカルで出るのに本番だけ落ちるのは、フォントファイルがサーバーレスのバンドルに含まれていないから。Next.jsのバージョンで書き方が変わる点も添える。
Next.js の next/og(ImageResponse)は、記事タイトルなどを流し込んだOG画像(SNSでシェアされたときに出る横長カード)をリクエストごとに自動生成できる。英語だけなら公式サンプルそのままで動くが、日本語を出そうとすると2箇所で詰まる。両方ハマったのでまとめる。
その1:日本語が「豆腐」になる——フォントは自分で渡す
事象
ImageResponse に日本語を渡すと、日本語のところだけ □(豆腐)になる。英数字は普通に出るのに、日本語だけ全部四角、という状態だ。
原因
next/og の中身は Satori というライブラリで、渡されたフォントデータの範囲でしか字を描けない。ブラウザやOSのように「システムに入っているフォントを勝手に使う」ことはしない。next/og はデフォルトで Noto Sans(英数字向け)を1本だけ積んでいるので英数字は何もしなくても出るが、日本語のグリフ(字形)はそのフォントに無いので豆腐になる。
かといって「Mac の Hiragino を読ませればいい」とはいかない。OS同梱の商用フォントはライセンス上リポジトリに置いて配布できないし、そもそもサーバー(Vercel等)には入っていない。
解決策
OFL(再配布可能なライセンス)の日本語フォント——Noto Sans JP、Zen系、M PLUS あたりが定番——を1つプロジェクトに置き、読み込んで ImageResponse の fonts に渡す。
import { ImageResponse } from "next/og";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
export const runtime = "nodejs";
export async function GET() {
const font = await readFile(join(process.cwd(), "assets/YourJPFont-Bold.ttf"));
return new ImageResponse(
<div style={{ display: "flex", fontFamily: "JP", fontSize: 64 }}>
日本語のタイトル
</div>,
{
width: 1200,
height: 630,
fonts: [{ name: "JP", data: font, weight: 700, style: "normal" }],
}
);
}
- フォントは必要な太さ(weight)だけでいい。見出し用に Bold 1本、程度で足りることが多い
- フォントを
fsで読むなら、そのルートを Node.js ランタイムにする(export const runtime = "nodejs")。現行の Next.js は既定が Node なので宣言は保険だが、書いておくと確実だ(昔のnext/ogは Edge 必須でfsが使えず、ここでコケる経緯があった) - App Router なら import は
next/og。Pages Router や Next.js の外で使うときは@vercel/og(中身は同じ) - 余談だが、Satori では子ノードが2つ以上ある要素に
display: "flex"(か"none")を明示しないとエラーになる。レイアウトを組み始めると必ず出会う
補足を1つ。この「Node.js ランタイムで fs からフォントを読む」方式は、Edge 実装で効く 500KB のバンドル上限に縛られない。数MBある日本語フォントをそのまま使えるのはこの方式の利点だ。ただしその代わり、次の落とし穴を踏む。
その2:ローカルでは出るのに、Vercelにデプロイすると落ちる
事象
その1で日本語が出るようになり、next dev でもプレビューでも問題なし。だが本番(Vercel)にデプロイすると、そのOG画像のURLだけ 500 になる。ログを見ると ENOENT(ファイルが無い)、フォントが見つからない、と出ている。
原因
Vercel(Next.js)はデプロイ時に、その関数が実際に使うファイルだけを自動で拾って同梱する(output file tracing)。この判定は @vercel/nft というツールがコードの import / require / fs の使用を静的に解析して——つまりコードを動かさず文字列として読んで——行う。だから join(process.cwd(), "assets/...") のように実行時に組み立てるパスは値が確定できず、追跡から漏れることがある。結果、コードは乗るがフォントは同梱されず、実行時に落ちる。ローカルには実ファイルがあるので再現しない、という厄介なパターンだ。
解決策
next.config の outputFileTracingIncludes で、そのルートに含めたいファイルを明示指定する。キーが対象ルート、値が同梱したいファイルの glob(** などのワイルドカードでファイルを指定する書き方)だ。
// next.config.ts(Next.js 15 以降)
const nextConfig = {
outputFileTracingIncludes: {
"/api/og": ["./assets/**/*"],
},
};
export default nextConfig;
バージョン注意:outputFileTracingIncludes が next.config の top-level に置ける正式オプションになったのは Next.js 15 から。13〜14 では experimental の下に置かないと効かない。
// Next.js 13〜14
const nextConfig = {
experimental: {
outputFileTracingIncludes: {
"/api/og": ["./assets/**/*"],
},
},
};
「ローカルは通るのに本番だけ落ちる」「エラーがファイル不在系」の2点が揃ったら、まずこれを疑う。なおこの指定が効くのは Node.js ランタイムのサーバー処理まで。Edge ランタイムで動かしている場合は別の対処が要る。
まとめ
- 日本語が豆腐 → フォントを自分で渡す。システムフォントは使えないので、OFL等の再配布可能なフォントを同梱する
- ローカルは出るのに本番で落ちる →
outputFileTracingIncludesでフォントをバンドルに明示的に含める(Next.js 15 で top-level に昇格。14 以前はexperimentalの下)
OG画像はいったん仕組みが通れば、あとは記事ごとに自動で生成される。最初のこの2箇所だけ越えればいい。