App Router で動的にOGP画像を作りたい【Next.js】
Next.js14 の og:image
Metadata
型のJSON形式にopenGraph
というプロパティがあります。
openGraph: {
title,
description,
url,
siteName,
images: {
url: imageURL,
width: 1200,
height: 630
},
type,
}
images
に画像を取得できるURLを当てはめると自動的に<head />
へ挿入されます。
例えば、
images: `${process.env.NEXT_PUBLIC_URL}/open-graph.png`
とすれば、app
ディレクトリの直下のopen-graph.pngを参照してくれます。
動的に画像を生成する
Next.js App Routerではopengraph-image.(png | jpg | ...)
というファイルをpage.tsx
と同じディレクトリに配置するだけで自動で<head />
へ追加されます。その際に、Metadata
のmetadataBase
プロパティが参照され、metadataBase/(opengraph-img)があるディレクトリ/opengraph-image.png
のように形作られます。
また画像ファイルではなくJSX / TSX ファイルを置くこともできます。
import { ImageResponse } from 'next/og'
export const runtime = 'edge'
export const alt = 'TITLE'
export const size = {
width: 1200,
height: 630,
}
export const contentType = 'image/png'
export default async function Image() {
return new ImageResponse(
(
<div
style={{
fontSize: 128,
background: 'white',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
TITLE
</div>
),
{
...size,
}
)
}
公式ドキュメントから少し抜粋しました。
JSX記法で画像を構成し、200ステータスレスポンスでog:imageとして利用できるようになります。
サンプルではCSSをstyleプロパティで記述していますが、全て対応しているわけではありません。
ここに記載のプロパティのみが有効で、それ以外を宣言すると500エラーレスポンスを吐き出します。
Dynamic Routingでの書き方
公式ドキュメントにある通り、page.tsx
と同じ書き方で実現できます。
export default function Image({ params }: { params: { slug: string } }) {
// ...
console.log(params.slug);
}
発生する問題
ここまでは良かったのです。。。例えば、掲載中のシリーズ一覧を表示するページがあり、特定のシリーズをクリックするとシリーズの詳細が表示される...の場合は
.
└── app/
├── series/
│ ├── [slug]/
│ │ ├── page.tsx
│ │ └── opengraph-image.tsx
│ └── page.tsx
├── page.tsx
└── opengraph-image.tsx
/series/page.tsx
と/series/[slug]/page.tsx
上述のページを分けて表示できますし、params.slug
でお好みのOGP画像を作ることができます。
では、こちらはどうでしょうか。
.
└── app/
├── post/
│ ├── [...slug]/
│ │ ├── page.tsx
│ │ └── opengraph-image.tsx
│ └── page.tsx
└── page.tsx
[slug]
から[...slug]
に変わりました。型でいうと、
{ params: { slug: string } }
{ params: { slug: string[] } }
このように変わります。/post/nextjs/ogp
というパスが与えられた場合、
paams.slug = ["nextjs", "ogp"];
と格納されます。これをCatch-all セグメントと呼びます。
では、同じようにImageResponseを作ろうとすると...
export default function Image({ params }: { params: { slug: string[] } }) {
// ...
console.log(params.slug.join('/'));
}
と書けますが実は使えないです。 私も「これ」を作るにあたって、同じ問題に遭いました。
調べると Issue でも取り上げられていました。
簡単に言うと、Catch-all下にサブルートがあるから実現できないよ!ということです。
サブルートは、/
以外の/post
や/series/about
といったトップページ以外のディレクトリで指されるページです。今回の例ではopengraph-image.tsx
とpage.tsx
がparams.slug
を使うサブルートとして共存するからではないでしょうか。
but all the possible paths including the request url paths to og image are caught by the catch-all routes.
OGP画像へのパスを含む全てのパスがCatch-allされるため、論理的にアクセスが成り立たなくなるようですね。
Issue の方ではslugを利用していなかったのでRoute-Groupsを作ってOGP画像を切り替えたらどうか、という話でつながっていきます。
回避策
Page Routerの時と同じ方法で解決しましょう。
このURLで上の画像が取得できます。即ち、API RouteとDynamic Routingを組み合わせた手法です。
import { NextRequest } from "next/server";
import { ImageResponse } from "next/og";
import { getPost } from "@/lib/getPosts";
export async function GET(req: NextRequest, context: { params: { slug: string[] } }) {
const slug = decodeURIComponent(context.params.slug.join('/'));
const { data } = await getPost(`${process.env.GIT_POSTS_DIR!}/${slug}.md`);
return new ImageResponse(<div style={{
width: "100%",
height: "100%",
backgroundImage: `url(${process.env.NEXT_PUBLIC_URL}/ogp_back.png)`,
justifyContent: "center",
display: "flex",
alignItems: "center",
flexDirection: "column",
fontSize: "50px"
}}>
<div style={{
width: "70%",
lineHeight: "1.2",
fontWeight: "bold"
}}>{data.title}</div>
</div>,
{
width: 1200,
height: 630,
status: 200
}
)
}
API を簡単に用意できるのは本当に助かります。context
引数は現状params
だけを持っているようです。
おわりに
Catch-all について仕様が理解できた気がします。
opengraph-imageは静的な場合に非常に使えそうですが、動的の場合はまだまだAPI Routeの便利さに頼ることになりそうです。
他の改善方法等があるなら、今後試してみたいですね。