Next.js generateMetadata — 動的SEOメタデータの自動生成
Next.js App Router では generateMetadata 関数を使うことで、ページごとに異なる SEO メタデータを型安全に生成できる。
この記事では、ブログ記事ページを例に title・description・OGP・Twitter Card を動的に組み立てる実装パターンを紹介する。
generateMetadata とは
generateMetadata は App Router(app/ ディレクトリ)専用の非同期関数で、各ページファイルから export することでそのページの <head> タグ内のメタデータを動的に生成できる。
// app/posts/[slug]/page.tsx
import type { Metadata } from "next";
export async function generateMetadata({ params }: Props): Promise<Metadata> {
// ページのパラメータを受け取り Metadata オブジェクトを返す
return {
title: "記事タイトル",
description: "記事の説明",
};
}
Pages Router で使っていた <Head> コンポーネントや next/head は不要になり、型安全な Metadata オブジェクトで一元管理できるようになる。
基本的な実装
ブログ記事ページの例
// src/app/posts/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import matter from "gray-matter";
import fs from "fs";
import path from "path";
type Props = {
params: { slug: string };
};
async function getPost(slug: string) {
const filePath = path.join(process.cwd(), "posts", `${slug}.md`);
if (!fs.existsSync(filePath)) return null;
const fileContents = fs.readFileSync(filePath, "utf8");
const { data, content } = matter(fileContents);
return { data, content };
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(params.slug);
if (!post) return { title: "記事が見つかりません" };
const { data } = post;
return {
title: `${data.title} | Maemaemae Blog`,
description: data.description ?? "",
};
}
export default async function PostPage({ params }: Props) {
const post = await getPost(params.slug);
if (!post) notFound();
// ...
}
generateMetadata と default export のページコンポーネントが同じファイルに共存できる点が、App Router の大きな特徴。
データ取得を共通化したい場合は、後述の cache() を使う。
OGP・Twitter Card を含む完全実装
SNS シェア時のカード表示まで対応する場合、openGraph と twitter フィールドを追加する。
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(params.slug);
if (!post) return { title: "Not Found" };
const { data } = post;
const title = data.title as string;
const description = (data.description ?? "") as string;
const image = data.image as string | null;
const siteUrl = "https://example.com";
const postUrl = `${siteUrl}/posts/${params.slug}`;
return {
title: `${title} | Maemaemae Blog`,
description,
// OGP (Open Graph Protocol)
openGraph: {
type: "article",
url: postUrl,
title,
description,
publishedTime: data.date,
images: image
? [{ url: `${siteUrl}${image}`, width: 1200, height: 630, alt: title }]
: [],
},
// Twitter Card
twitter: {
card: image ? "summary_large_image" : "summary",
title,
description,
images: image ? [`${siteUrl}${image}`] : [],
},
};
}
各フィールドの用途
| フィールド | 用途 |
|---|---|
title | <title> タグ / OGP タイトル |
description | <meta name="description"> |
openGraph.type | "article" を指定すると公開日・著者情報が有効になる |
openGraph.publishedTime | 記事の公開日(ISO 8601 形式) |
openGraph.images | SNS シェア時のサムネイル画像 |
twitter.card | "summary" または "summary_large_image" |
データ取得の二重実行を防ぐ — React cache()
generateMetadata とページコンポーネントの両方でデータ取得をすると、同じファイルへのアクセスが 2 回発生する。
React 18 の cache() でメモ化すると、同一リクエスト内で結果が共有される。
// src/app/posts/[slug]/page.tsx
import { cache } from "react";
import matter from "gray-matter";
import fs from "fs";
import path from "path";
// cache() でラップすることで同一リクエスト内での二重実行を防ぐ
const getPost = cache(async (slug: string) => {
const filePath = path.join(process.cwd(), "posts", `${slug}.md`);
if (!fs.existsSync(filePath)) return null;
const fileContents = fs.readFileSync(filePath, "utf8");
const { data, content } = matter(fileContents);
return { data, content };
});
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(params.slug); // 1 回目のアクセス
// ...
}
export default async function PostPage({ params }: Props) {
const post = await getPost(params.slug); // キャッシュが返る(ファイルアクセスなし)
// ...
}
next/cache の unstable_cache はリクエストをまたいだキャッシュ用途で、今回のようなリクエスト内の共有には react の cache() が適切。
なお、fetch() を使ったデータ取得の場合は Next.js が自動でメモ化するため cache() のラップは不要。
fs.readFileSync など fetch を使わないケースで cache() が有効になる。
静的エクスポート (output: "export") での注意点
next.config.js で output: "export" を設定している場合、generateMetadata は ビルド時 に実行される。
動的な値(データベースや外部 API)を参照する場合は、generateStaticParams と合わせてビルド時にパスを確定させる必要がある。
// ビルド時にすべてのスラッグを列挙する
export async function generateStaticParams() {
const postsDirectory = path.join(process.cwd(), "posts");
const filenames = fs.readdirSync(postsDirectory);
return filenames
.filter((name) => name.endsWith(".md"))
.map((name) => ({ slug: name.replace(/\.md$/, "") }));
}
generateMetadata は generateStaticParams で返したパスに対してのみ呼ばれるため、両方をセットで実装する。
デフォルトメタデータとのマージ
サイト全体のデフォルト値はルートの layout.tsx で定義し、ページごとに上書きするパターンが一般的。
// src/app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: {
default: "Maemaemae Blog",
template: "%s | Maemaemae Blog", // 子ページのタイトルに自動付与
},
description: "フロントエンド・ゲーム開発の技術ブログ",
openGraph: {
siteName: "Maemaemae Blog",
locale: "ja_JP",
type: "website",
},
};
title.template を設定すると、子ページで title: "記事タイトル" と書くだけで "記事タイトル | Maemaemae Blog" に自動変換される。
generateMetadata 内では title: data.title と返すだけでよく、サフィックスの重複を防げる。
ハマりやすいポイント
1. params の型が string | string[] になる場合
params.slug は string のはずだが、TypeScript の型推論によっては string | string[] になることがある。
明示的な型アノテーションで回避する。
type Props = {
params: { slug: string }; // 明示的に string と定義する
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const slug = params.slug; // string として扱える
// ...
}
2. notFound() を generateMetadata 内で呼ぶと型エラー
generateMetadata は Metadata を返す必要があるため、notFound() を呼び出せない(never 型を返すが型推論がずれることがある)。
記事が存在しない場合は最低限のメタデータを返しておき、ページコンポーネント側で notFound() を呼ぶのが安全。
// generateMetadata 内
if (!post) return { title: "Not Found" }; // notFound() は呼ばない
// ページコンポーネント内
if (!post) notFound(); // こちらで 404 処理する
3. OGP 画像 URL に絶対 URL が必要
openGraph.images には相対パスではなく絶対 URL が必要。
/images/xxx.jpg のような相対パスを渡すと、SNS のクローラーが画像を取得できない。
// NG
images: [{ url: "/images/ogp.jpg" }]
// OK
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://example.com";
images: [{ url: `${siteUrl}/images/ogp.jpg` }]
環境変数 NEXT_PUBLIC_SITE_URL にサイトの URL を設定し、generateMetadata 内で参照するとローカル・本番で切り替えやすい。
業務での使いどころ
多言語サイト (i18n) でのメタデータ切り替え
params.locale を受け取り、言語ごとに title・description を切り替えるパターンで活用できる。
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug, locale } = params;
const post = await getPost(slug, locale);
return {
title: post.title,
description: post.description,
alternates: {
canonical: `/${locale}/posts/${slug}`,
languages: {
"ja-JP": `/ja/posts/${slug}`,
"en-US": `/en/posts/${slug}`,
},
},
};
}
alternates.languages を設定すると <link rel="alternate" hreflang="..."> が自動生成され、Google の多言語サイト評価に寄与する。
E コマースサイトの商品ページ
商品 ID から API を呼び出して、商品名・価格・画像を動的に取得してメタデータを組み立てるパターン。
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const product = await fetchProduct(params.id);
return {
title: `${product.name} — ${product.price}円 | ショップ名`,
description: product.description.slice(0, 120),
openGraph: {
images: [{ url: product.imageUrl, width: 1200, height: 630 }],
},
};
}
まとめ
generateMetadataは App Router 専用の非同期関数で、型安全に<head>のメタデータを動的生成できるopenGraphとtwitterフィールドを組み合わせることで OGP・Twitter Card も一元管理できるreactのcache()で、generateMetadataとページコンポーネントのデータ取得の二重実行を防げるoutput: "export"ではgenerateStaticParamsとセットで実装する必要がある- ルートの
layout.tsxでtitle.templateを設定すると、子ページのタイトル管理がシンプルになる