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> {
  // ページのパラメータを受け取り 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();
  // ...
}

generateMetadatadefault export のページコンポーネントが同じファイルに共存できる点が、App Router の大きな特徴。 データ取得を共通化したい場合は、後述の cache() を使う。

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 = "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.imagesSNS シェア時のサムネイル画像
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/cacheunstable_cache はリクエストをまたいだキャッシュ用途で、今回のようなリクエスト内の共有には reactcache() が適切。 なお、fetch() を使ったデータ取得の場合は Next.js が自動でメモ化するため cache() のラップは不要。 fs.readFileSync など fetch を使わないケースで cache() が有効になる。

静的エクスポート (output: "export") での注意点

next.config.jsoutput: "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$/, "") }));
}

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

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

サイト全体のデフォルト値はルートの 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.slugstring のはずだが、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 内で呼ぶと型エラー

generateMetadataMetadata を返す必要があるため、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 を受け取り、言語ごとに titledescription を切り替えるパターンで活用できる。

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