はじめに
ブログ記事ごとにOG画像(いわゆるOGP画像)を自動生成する仕組みは、Next.js の next/og(中身は Satori)や Cloudflare Workers などで簡単に作れるようになりました。英語の記事ならサンプルコードをコピーするだけでほぼ動きます。
ところが、タイトルが日本語になった途端に次のような問題が起きがちです。
- 生成された画像の日本語がすべて「□□□□」(いわゆる豆腐)になる
- 「Next.jsで始め / る」のように、単語の途中で変な改行が入る
- X や Slack に貼ると、カードのタイトルが文字化けする
- フォントを読み込んだら関数のサイズ制限に引っかかる
- 直したのに Facebook や LinkedIn のカードが古いまま
この記事では、日本語サイトでリンクプレビューを作るときに実際にハマりやすいポイントを5つに絞って、原因と対処法をまとめます。コードは Next.js(App Router)前提ですが、考え方は他のフレームワークでも同じです。
1. 豆腐文字:Satoriは日本語フォントを自分で持っていない
ImageResponse は、指定がなければ組み込みの欧文フォントだけで描画します。日本語のグリフが含まれていないので、日本語部分は豆腐になります。
対処法は「日本語フォントを ArrayBuffer として渡す」ことです。ただし Noto Sans JP のような日本語フォントはファイルサイズが大きく、丸ごと同梱するとビルドサイズや関数のサイズ制限に響きます。
そこで便利なのが、Google Fonts の text= パラメータで使う文字だけをサブセット化して取得する方法です。
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { getPost } from "@/lib/posts";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
async function loadGoogleFont(family: string, weight: number, text: string) {
const url =
`https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@${weight}` +
`&text=${encodeURIComponent(text)}`;
const css = await (await fetch(url)).text();
// Satoriはwoff2非対応。ブラウザ以外のUAで取得するとtruetype/opentypeが返る
const match = css.match(/src: url\((.+?)\) format\('(opentype|truetype)'\)/);
if (!match) throw new Error("フォントの取得に失敗しました");
const res = await fetch(match[1]);
return res.arrayBuffer();
}
export default async function Image({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
const title = post?.title ?? "記事タイトル";
const siteName = "Field Notes";
// 画像内で使う文字をすべて渡す(タイトル+サイト名など)
const font = await loadGoogleFont("Noto Sans JP", 700, title + siteName);
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: 72,
background: "#0f172a",
color: "#f8fafc",
fontFamily: "Noto Sans JP",
}}
>
<div style={{ fontSize: 28, opacity: 0.7 }}>{siteName}</div>
<div style={{ fontSize: 60, fontWeight: 700, lineHeight: 1.35 }}>{title}</div>
<div style={{ fontSize: 24, opacity: 0.6 }}>example.com</div>
</div>
),
{ ...size, fonts: [{ name: "Noto Sans JP", data: font, weight: 700, style: "normal" }] }
);
}
ポイントは3つです。
-
text=に画像内で描画するすべての文字を渡す。サイト名や日付を後から足したのに渡し忘れると、その部分だけ豆腐になります。 - Satori は woff2 を読めません。上のように
truetype/opentypeの URL を取り出します。 - 生成時に外部へ fetch するので、ビルド時に静的生成するか、生成結果がCDNにキャッシュされる構成にしておくと安定します。
2. 改行位置:日本語は「どこで折り返すか」を自分で決める必要がある
英語はスペースで折り返せますが、日本語にはスペースがありません。Satori に長い日本語タイトルを渡すと、文字単位で機械的に折り返されるため、「Next.jsで始め」「る」のような読みにくい改行になります。
ここで使えるのが Google の BudouX です。日本語の文を「文節っぽい単位」に区切ってくれる軽量ライブラリで、辞書を持たないのでサイズも小さめです。
import { loadDefaultJapaneseParser } from "budoux";
const parser = loadDefaultJapaneseParser();
function Title({ text }: { text: string }) {
const chunks = parser.parse(text); // 例: ["日本語の", "OG画像で", "ハマる", "5つの", "ポイント"]
return (
<div style={{ display: "flex", flexWrap: "wrap", fontSize: 60, fontWeight: 700, lineHeight: 1.35 }}>
{chunks.map((c, i) => (
<span key={i}>{c}</span>
))}
</div>
);
}
各チャンクを flexWrap: "wrap" のコンテナに並べると、チャンクの境目でだけ折り返されるようになります。
あわせて、タイトルの長さで文字サイズを変えるのもおすすめです。日本語は1文字あたりの幅が広いので、英語と同じ感覚で 60px 固定にすると4行以上になってはみ出します。
const fontSize = title.length > 40 ? 44 : title.length > 26 ? 52 : 60;
3. カードの文字化け:HTMLの文字コード宣言を確認する
OG画像ではなく、カードのタイトルや説明文が文字化けするケースもあります。古い CMS や Shift_JIS / EUC-JP で運用しているサイトでよく見かけます。
クローラーは、HTTP レスポンスの Content-Type ヘッダーか、HTML 内の <meta charset> を見て文字コードを判断します。どちらも無い、あるいは食い違っていると、推測に失敗して文字化けします。
チェックするのは次の3点です。
<head>
<!-- 先頭1024バイト以内に置く(HTML仕様上の要件) -->
<meta charset="utf-8" />
<meta property="og:title" content="日本語のタイトル" />
<meta property="og:locale" content="ja_JP" />
...
</head>
-
<meta charset="utf-8">を<head>のできるだけ先頭に置く - サーバーの
Content-Type: text/html; charset=utf-8と一致させる - 日本語ページなら
og:localeをja_JPにしておく
curl で実際のレスポンスヘッダーを見るのが一番確実です。
curl -sI https://example.com/blog/my-post | grep -i content-type
4. ファイルサイズ:フォントと画像の両方を軽くする
日本語フォントを丸ごと同梱すると、それだけで数MBになることがあります。Vercel などのサーバーレス環境では関数のサイズ制限があり、デプロイに失敗したり、コールドスタートが遅くなったりします。前述の text= によるサブセット化は、このサイズ問題の対策にもなります。
生成される画像側のサイズにも注意が必要です。グラデーションや写真を背景に使うと PNG が重くなりがちで、重すぎる画像はプラットフォーム側がダウンロードを諦めてカードが画像なしになることがあります。
- 背景は単色かシンプルなグラデーションにする
- 写真を使うなら JPG で書き出す
- サイズは 1200×630 を基準にし、重要な文字は中央寄りに置く(アプリごとにトリミングが違うため)
生成した画像が本当に読み込めているか、サイズが期待どおりかは、ブラウザで画像 URL を直接開くだけでなく、外部のサーバーから取得した結果で確認するのが安全です。私は OG Scope の OG画像チェッカー を使っています。公開ページの URL を貼るだけで、og:image に書かれた値、相対パスを解決した絶対 URL、画像が実際に返ってきたか、読み取れる場合はピクセルサイズまで表示してくれます。JavaScript を実行せずに生の HTML を読むので、「クライアント側でしかメタタグを入れていない」問題も見つけやすいです。
5. キャッシュ:直したのにカードが古いまま
最後に一番多い質問です。「画像を直したのに、SNSに貼ると古いカードのまま」。
これは各プラットフォームが最初に取得した結果をキャッシュしているからです。しかもキャッシュはサービスごとにバラバラで、更新方法も違います。
| サービス | 主な読み取りタグ | 更新方法 |
|---|---|---|
og:* |
シェアデバッガーで「もう一度スクレイピング」 | |
| X |
twitter:*、無ければ og:* にフォールバック |
時間経過を待つ(URLを変えるのが確実) |
og:*(twitter:image は使わない) |
Post Inspector で再取得 | |
| Slack / Discord / LINE など | og:* |
明示的な更新手段がほぼ無い |
確実なのは、画像を差し替えたら URL も変えることです。
// 例: 記事の更新日時をクエリに付ける
export async function generateMetadata({ params }) {
const post = await getPost(params.slug);
const v = new Date(post.updatedAt).getTime();
return {
openGraph: {
images: [{ url: `/blog/${params.slug}/opengraph-image?v=${v}`, width: 1200, height: 630 }],
},
};
}
同じ URL に新しい画像を上書きすると、古いカードがずっと残ります。
X 向けには、twitter:card を summary_large_image に明示しているかも確認してください。指定が無いと小さい summary 扱いになり、せっかくの横長画像が小さなサムネイルになります。どのタグが読まれ、どれが og:* へのフォールバックになっているかは Twitterカードチェッカー で確認できます。Facebook のキャッシュの仕組みとワイド表示時のトリミングについては、Facebookリンクプレビューの解説ページ が分かりやすいです。
まとめ:公開前チェックリスト
日本語サイトでリンクを共有する前に、これだけ確認しておけば大きな事故は防げます。
-
OG画像に日本語フォントを渡している(
text=で使う文字をすべて含めた) - 長いタイトルでも不自然な改行やはみ出しが無い(BudouX+文字サイズ調整)
-
<meta charset="utf-8">が head の先頭付近にあり、Content-Typeと一致している -
og:imageが絶対 URL で、外部から取得すると 200 で画像が返る - 画像は 1200×630 前後で、重すぎない
-
twitter:cardを意図して設定している - 画像を差し替えたら URL も変えた
英語のサンプルをそのまま使うと見落としがちな部分ばかりですが、一度仕組みを作ってしまえば、あとは記事を書くたびにきれいなカードが自動で出てくるようになります。参考になれば嬉しいです。