Next.js generateMetadata — 動的 SEO メタデータの自動生成

Next.js App Router では generateMetadata 関数を使うことで、ページごとに異なる SEO メタデータを型安全に生成できます。この記事では、ブログ記事ページを例に titledescription・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();
  // ...
}

generateMetadatadefault export のページコンポーネントが同じファイルに共存できる点が、App Router の大きな特徴です。

OGP・Twitter Card を含む完全実装

SNS シェア時のカード表示まで対応する場合、openGraphtwitter フィールドを追加します。

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.imagesSNS シェア時のサムネイル画像
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$/, "") }));
}

generateMetadatagenerateStaticParams で返したパスに対してのみ呼ばれるため、両方をセットで実装します。

デフォルトメタデータとのマージ

サイト全体のデフォルト値はルートの 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 内で呼ぶ

generateMetadataMetadata を返す必要があるため、記事が存在しない場合は最低限のメタデータを返しておき、ページコンポーネント側で 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> のメタデータを動的生成できる
  • openGraphtwitter フィールドを組み合わせることで OGP・Twitter Card も一元管理できる
  • reactcache() で、generateMetadata とページコンポーネントのデータ取得の二重実行を防げる
  • output: "export" では generateStaticParams とセットで実装する必要がある
  • ルートの layout.tsxtitle.template を設定すると、子ページのタイトル管理がシンプルになる