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> {
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} | My 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 の大きな特徴です。
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 = process.env.NEXT_PUBLIC_SITE_URL ?? "https://example.com";
const postUrl = `${siteUrl}/posts/${params.slug}`;
return {
title,
description,
openGraph: {
type: "article",
url: postUrl,
title,
description,
publishedTime: data.date,
images: image
? [{ url: `${siteUrl}${image}`, width: 1200, height: 630, alt: title }]
: [],
},
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 の cache() でメモ化すると、同一リクエスト内で結果が共有されます。
// src/app/posts/[slug]/page.tsx
import { cache } from "react";
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); // キャッシュが返る(ファイルアクセスなし)
// ...
}
fetch() を使ったデータ取得の場合は Next.js が自動でメモ化するため cache() のラップは不要です。fs.readFileSync など fetch を使わないケースで有効になります。
静的エクスポート (output: “export”) での注意点
output: "export" を設定している場合、generateMetadata はビルド時に実行されます。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: "My Blog",
template: "%s | My Blog", // 子ページのタイトルに自動付与
},
description: "技術ブログ",
openGraph: {
siteName: "My Blog",
locale: "ja_JP",
type: "website",
},
};
title.template を設定すると、子ページで title: "記事タイトル" と書くだけで "記事タイトル | My Blog" に自動変換されます。
ハマりやすいポイント
1. params の型が string | string[] になる場合
type Props = {
params: { slug: string }; // 明示的に string と定義する
};
2. notFound() を generateMetadata 内で呼ぶ
generateMetadata は Metadata を返す必要があるため、記事が存在しない場合は最低限のメタデータを返しておき、ページコンポーネント側で notFound() を呼ぶのが安全です。
// generateMetadata 内
if (!post) return { title: "Not Found" }; // notFound() は呼ばない
// ページコンポーネント内
if (!post) notFound(); // こちらで 404 処理する
3. OGP 画像 URL に絶対 URL が必要
// NG: 相対パスだと SNS クローラーが画像を取得できない
images: [{ url: "/images/ogp.jpg" }]
// OK
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://example.com";
images: [{ url: `${siteUrl}/images/ogp.jpg` }]
まとめ
generateMetadataは App Router 専用の非同期関数で、型安全に<head>のメタデータを動的生成できるopenGraphとtwitterフィールドを組み合わせることで OGP・Twitter Card も一元管理できるreactのcache()で、generateMetadataとページコンポーネントのデータ取得の二重実行を防げるoutput: "export"ではgenerateStaticParamsとセットで実装する必要がある- ルートの
layout.tsxでtitle.templateを設定すると、子ページのタイトル管理がシンプルになる