Next.js App RouterのSEO実装|generateMetadataの最適化と共通管理テクニック

WEBマーケティング
公開2025年12月9日
更新2025年12月9日
Next.js App RouterのSEO実装|generateMetadataの最適化と共通管理テクニック

Next.js App Routerでは、generateMetadata 関数と metadata オブジェクトを使用してSEOタグを管理するのが標準であり、これにより動的なOGP設定やCanonicalの自動化が容易になります。

  • 「App RouterでのSEO設定方法がわからない」
  • 「ブログ記事ごとの動的なメタデータをどう生成すべきか」
  • 「OGP画像の設定や、サイト全体の統一管理が面倒だ」

このような悩みを持つNext.js開発者は少なくありません。

本記事では、Metadata APIの基本実装から、conetsでも採用している共通ロジックによる管理手法、metadataBase の活用、そして動的データ取得時のパフォーマンスへの影響までを徹底解説します。

Next.js App RouterにおけるMetadata APIの基本

Next.js App RouterにおけるMetadata APIの基本

Next.js App Routerでは Metadata API により、SEOタグを静的・動的に統一管理できます。

従来の Head コンポーネントに代わり、静的なページでは export const metadata を、動的なページではexport async function generateMetadata を定義することで、<head> 内のタグをサーバーサイドで生成・出力します。

これにより、SEOとパフォーマンスの両立が可能になります。

静的メタデータ(Static Metadata)の定義

トップページや会社概要など、内容が固定されているページでは、layout.tsx や page.tsx で metadata オブジェクトを定数としてエクスポートします。
ここで定義した内容は、子階層のページにも継承されるため、layout.tsx でサイト全体の共通設定(デフォルトのタイトルやOGPなど)を行っておくのが定石です。

動的メタデータ(Dynamic Metadata)の生成

ブログ記事詳細ページ([slug]/page.tsx)のように、URLによって内容が変わるページでは、generateMetadata 関数を使用します。
この関数内でURLパラメータ(slug)を受け取り、データベースやCMSから記事データを取得して、動的にタイトルやOGP画像を設定します。

Static metadata vs generateMetadata vs head.js(旧API)比較表

Static Metadata

用途

静的ページ(Top, About等)

定義方法

export const metadata

データ取得

不可(定数のみ)

継承

親layoutから継承される

推奨度

★★★ (基本)

generateMetadata

用途

動的ページ(Blog詳細等)

定義方法

export async function

データ取得

可能(async/await)

継承

親layoutから継承される

推奨度

★★★ (動的)

head.js (旧)

用途

Next.js 13初期の仕様

定義方法

export default function Head

データ取得

可能

継承

継承されない(上書き)

推奨度

× (非推奨)

シーン別:どちらを使うべきかの判断基準

実務では、この2つを明確に使い分ける必要があります。

  1. Static Metadataを使うべきシーン
    • ルートレイアウト(app/layout.tsx): サイト全体のデフォルトタイトル、OGP画像、favicon、metadataBase などを設定する場所です。ここで設定した内容は全ページに継承されるため、個別のページで設定し忘れても「最低限のSEOタグ」が出力される安全装置になります。
    • 固定ページ(app/about/page.tsxなど): 会社概要やお問い合わせフォームなど、DBからデータを取得する必要がないページです。title: '会社概要' のようにシンプルに記述します。
  2. generateMetadataを使うべきシーン
    • 動的ルート(app/blogs/[slug]/page.tsx): 記事のタイトルやアイキャッチ画像をCMSから取得して反映させる場合です。
    • 多言語対応ページ(app/[lang]/page.tsx): URLの言語パラメータ(jaやen)に応じて、タイトルを「ホーム」や「Home」に切り替える場合です。
    • クエリパラメータで内容が変わるページ: 検索結果ページ(app/search/page.tsx)などで、?q=nextjs というクエリを受け取り、タイトルを「"nextjs"の検索結果」とする場合です。

Metadata APIの内部挙動とレンダリング

なぜ generateMetadata はサーバーコンポーネントでしか動かないのでしょうか?
それは、Next.jsがHTMLを生成するプロセス(レンダリング)の順序に関係しています。

  • ビルド時(Static Rendering): 静的な metadata はビルド時に解決され、HTMLの <head> に埋め込まれます。
  • リクエスト時(Dynamic Rendering): generateMetadata はリクエストが来るたびにサーバー側で実行され、DBからデータを取得して <head> を生成します。

クライアントコンポーネント("use client")は、ブラウザ側でJavaScriptを実行して画面を描画しますが、SEOにとって重要な <title> や <meta> タグは、ブラウザがJSを実行する前(最初のHTMLレスポンス)に含まれている必要があります。

そのため、Metadata APIはサーバーサイドでのみ動作するように設計されているのです。

generateMetadataによる動的SEO設定の実装パターン

generateMetadataによる動的SEO設定の実装パターン

generateMetadata 関数は、params や searchParams を引数として受け取り、非同期でデータを取得してメタデータオブジェクトを返します。

Next.js公式ドキュメントによれば、この関数内で fetch を使用しても、同一リクエスト内であれば自動的にメモ化(Request Memoization)されるため、ページコンポーネントと同じデータを取得してもパフォーマンス低下の心配はありません。

ブログ記事など動的ページのタイトル・OGP設定

以下は、CMSから記事データを取得し、メタデータに反映させる基本的な実装例です。

// src/app/blogs/[slug]/page.tsx
import { Metadata } from 'next';
import { getBlogBySlug } from '@/lib/cms';
import { notFound } from 'next/navigation';

type Props = {
  params: { slug: string };
};

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  // 1. データを取得
  const post = await getBlogBySlug(params.slug);
  
  // 2. データがない場合の処理(後述)
  if (!post) {
      return {
          title: 'ページが見つかりません',
      };
  }

  // 3. メタデータを返す
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.ogImage?.url || '/default-ogp.png'],
    },
  };
}

export default async function Page({ params }: Props) {
  // ページコンポーネントでも同じデータを取得
  const post = await getBlogBySlug(params.slug);
  
  if (!post) notFound(); // 404ページへ

  // ...レンダリング処理
}

データ取得の重複とRequest Memoization

上記のコードを見て「getBlogBySlug を2回呼んでいるから無駄な通信が発生するのでは?」と心配になるかもしれません。
しかし、Next.jsの fetch(およびReactの cache でラップされた関数)は、1回のリクエストサイクル内で同じ引数で呼ばれた場合、結果を自動的にメモ化(キャッシュ)します。

【Request Memoizationの仕組み】

  1. ユーザーが /blogs/seo-guide にアクセスする。
  2. Next.jsサーバーがリクエストを受け取る。
  3. まず generateMetadata が実行され、getBlogBySlug('seo-guide') を呼ぶ。
    APIリクエスト発生(1回目)。結果をメモリに保存。
  4. 次に Page コンポーネントが実行され、getBlogBySlug('seo-guide') を呼ぶ。
    APIリクエストは発生せず、メモリから結果を即座に返す。

この仕組みのおかげで、開発者は「メタデータ用」と「ページ用」でデータをどう受け渡すか(Propsバケツリレーなど)を悩む必要がなくなり、必要な場所で必要なデータを取得するシンプルなコードを書くことができます。

エラーハンドリング(try-catch)と404処理

外部APIとの通信は失敗する可能性があります。

generateMetadata 内でエラーが発生すると、ページ全体がクラッシュする恐れがあります。

実務では try-catch ブロックで囲み、エラー時はデフォルトのメタデータを返すか、notFound() を呼び出して404ページへ誘導する処理を入れると堅牢性が高まります。

metadataBaseの設定とOGP画像の相対パス解決

metadataBaseの設定とOGP画像の相対パス解決

metadataBase は、OGP画像やCanonical URLなどの絶対パスを解決するためのベースURLを設定するプロパティです。

これをルートの layout.tsx に設定することで、各ページでは /images/ogp.png のような相対パスで記述できるようになり、開発環境(localhost)と本番環境(Vercel等)でのURL不整合を防げます。

環境変数を使ったベースURLの動的切り替え

layout.tsx で以下のように設定することで、環境に応じたドメインが自動的に適用されます。

// src/app/layout.tsx
import { Metadata } from 'next';

export const metadata: Metadata = {
  metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'),
  // ...その他の設定
};

URL解決の仕組み

metadataBase を設定すると、Next.jsは以下のようにURLを結合して出力します。

  • 設定: metadataBase: new URL('https://conets.jp')
  • 記述: openGraph: { images: '/ogp.png' }
  • 出力: <meta property="og:image" content="https://conets.jp/ogp.png" />

もし metadataBase が未設定の場合、Next.jsはビルド時に警告を出したり、localhost のURLがそのまま本番環境に出力されてしまったりするリスクがあります。

特にOGP画像は絶対パスでないとSNS側で認識されないため、この設定は必須です。

metadataBaseの失敗パターンと注意点

metadataBase は便利ですが、設定を誤ると予期せぬトラブルを招きます。

  1. 画像URLがlocalhostになる事故:
    metadataBase を設定せず、かつ NEXT_PUBLIC_SITE_URL も未定義の場合、Next.jsはデフォルトで http://localhost:3000 をベースURLとして使用することがあります。この状態で本番デプロイすると、OGP画像のURLが http://localhost:3000/ogp.png となり、SNSで画像が表示されません。
  2. パスの先頭スラッシュの有無:
    metadataBase に https://example.com/(末尾スラッシュあり)を設定し、画像パスを /ogp.png(先頭スラッシュあり)と書くと、URL結合時にスラッシュが重複する可能性があります(実装によっては自動補正されますが、注意が必要です)。基本的には metadataBase はドメインのみ、パスは / から始めるのが安全です。
  3. SPAライブラリ(React Helmet)との違い:
    React Helmetなどのクライアントサイドライブラリでは、ブラウザ上でURLを解決しますが、Next.jsのMetadata APIはサーバーサイドで解決します。そのため、window.location などのブラウザAPIを使って動的にベースURLを決めることはできません。

Vercel Preview環境での注意点

Vercelのプレビュー環境(プルリクエストごとのデプロイ)では、ドメインが動的に生成されます(例: project-git-branch-user.vercel.app)。
固定の NEXT_PUBLIC_SITE_URL だけではプレビュー環境でOGPが正しく表示されない場合があるため、process.env.VERCEL_URL が存在する場合はそれを優先して metadataBase に設定するロジックを入れると、プレビュー環境でもOGP確認が可能になります。

TwitterカードとOpen Graphの推奨設定

SNSシェア時の見栄えを良くするために、以下の設定を推奨します。

  • card: summary_large_image(大きな画像で表示)
  • images: 推奨サイズは 1200x630 ピクセル
  • type: 記事ページなら article、トップページなら website

実装後は、ラッコツールズ | OGP確認で正しく表示されるか確認しましょう。

canonical URL の自動生成と注意点

metadataBase を設定しておけば、metadata 内で alternates: { canonical: '/path' } と相対パスで指定するだけで、完全なURL(https://example.com/path)として出力されます。

canonical設定の落とし穴と事故例

特に注意が必要なのが、クエリパラメータ付きのURLです。

  1. ページネーション事故:
    ブログの一覧ページで ?page=2 にアクセスした際、canonicalが https://example.com/blog?page=2 になってしまうと、1ページ目と2ページ目が別々のページとして評価され、評価が分散します(または重複コンテンツとみなされます)。一般的には、2ページ目以降も https://example.com/blog に正規化するか、あるいは rel="prev/next" を併用する戦略が必要です。
  2. フィルタリング事故:
    ECサイトなどで ?sort=price(価格順)や ?color=red(色絞り込み)のURLが生成される場合、これらすべてに個別のcanonicalが付くと、検索エンジンは「無数の似たようなページ」をクロールすることになり、クロールバジェットを浪費します。
    このような場合、generateMetadata 内でクエリパラメータを除去した正規のURL(https://example.com/search)をcanonicalとして指定する必要があります。
// 検索ページの例:クエリパラメータを除外して正規化
export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
  return {
    title: `"${searchParams.q}" の検索結果`,
    alternates: {
      canonical: '/search', // クエリパラメータを含めない
    },
  };
}

【実例】conets流・メタデータ管理のベストプラクティス

【実例】conets流・メタデータ管理のベストプラクティス

conetsのサイトでは、メタデータ生成ロジックを src/lib/seo.ts に集約し、DRY(Don't Repeat Yourself)な設計を実現しています。

共通関数を通すことで、タイトルの接尾辞(|conets)の統一や、説明文の文字数制限、OGP画像のフォールバック処理を自動化し、運用コストを大幅に削減しています。

共通ロジック(seo.ts)による型の統一と自動化

各ページでバラバラにオブジェクトを書くのではなく、生成関数を通すことで統一感を保ちます。以下は実際に使えるコード例です。

// src/lib/seo.ts
import type { Metadata } from "next";

const SITE_NAME = "conets";
const SITE_BRAND = "conets";
export const SITE_URL =
  process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ??
  "https://conets.jp";

// SNSで壊れにくいPNGを推奨
const DEFAULT_OG_IMAGE = `${SITE_URL}/og-default.png`;

type GenerateMetadataArgs = {
  title?: string;
  description?: string;
  slug?: string;
  image?: string;
  ogType?: "website" | "article";
};

/**
 * 現代のSEO/LLMOベストプラクティスに基づいたメタデータ生成
 * - タイトル末尾は「|conets」固定(冗長な tagline は使わない)
 * - description がない場合はページ固有の title を fallback に
 * - canonical 自動生成
 */
export const generateMetadata = ({
  title,
  description,
  slug = "",
  image,
  ogType = "website",
}: GenerateMetadataArgs): Metadata => {
  const baseTitle = title?.trim();

  // title 最適化(ページ固有タイトル|conets)
  const finalTitle = baseTitle
    ? `${baseTitle}|${SITE_BRAND}`
    : SITE_BRAND; // TOPページなど title未指定時

  // canonical URL
  const normalizedSlug =
    slug === "" || slug === "/"
      ? ""
      : slug.startsWith("/")
      ? slug
      : `/${slug}`;

  const ogImage = image ?? DEFAULT_OG_IMAGE;

  const canonicalUrl = `${SITE_URL}${normalizedSlug}`;

  // description 最適化
  const desc = description?.trim();

  // fallback: ページ独自の title を使用し、Google推奨の 100〜160字に切り詰め
  const finalDescription = desc && desc.length > 0
    ? desc.length > 160
      ? desc.slice(0, 157) + "..."
      : desc
    : `${baseTitle ?? SITE_NAME} のページです。`;

  return {
    title: finalTitle,
    description: finalDescription,
    alternates: {
      canonical: canonicalUrl,
      types: {
        "application/rss+xml": [
          { url: `${SITE_URL}/rss.xml`, title: "RSS Feed" },
        ],
        "application/atom+xml": [
          { url: `${SITE_URL}/atom.xml`, title: "Atom Feed" },
        ],
      },
    },
    openGraph: {
      title: finalTitle,
      description: finalDescription,
      url: canonicalUrl,
      siteName: SITE_NAME,
      type: ogType,
      images: [
        {
          url: ogImage,
          width: 1200,
          height: 630,
          alt: finalTitle,
        },
      ],
    },
    twitter: {
      card: "summary_large_image",
      title: finalTitle,
      description: finalDescription,
      images: [ogImage],
    },
  };
};

なぜ共通関数(seo.ts)を作ったのか?

conetsでこの共通関数を導入する前は、各ページで metadata オブジェクトを手書きしていました。

しかし、運用を続けるうちに以下のような問題が発生するリスクがあると感じました。

  • タイトルの不統一: あるページは「|conets」、別のページは「- conets」と接尾辞がバラバラ。
  • OGP設定漏れ: 新しい記事を作った際、OGP画像の設定を忘れ、SNSでシェアされた時に画像が出ない事故が多発。
  • 説明文の文字数オーバー: 300文字以上の長い説明文をそのまま設定してしまい、検索結果で重要な部分が省略される。

constructMetadata関数を導入すると、これらの問題が「仕組み」で解決されました。

開発者は title だけ渡せば、あとは自動的に最適化されたメタデータが生成されるため、SEOの品質が均一化され、実装ミスも激減しました。

型安全なSEO設計(Zodバリデーションなど)

さらに堅牢にするなら、Zodなどのバリデーションライブラリを使って、入力値のチェックを行うことも可能です。

例えば、「タイトルは必須」「説明文は300文字以内」といったルールを型レベルで強制することで、ビルド時にミスを検知できる「型安全なSEO」を実現できます。

フォールバック戦略と404処理

  • OGP画像がない場合: 記事にアイキャッチが設定されていなければ、自動的にサイト共通のデフォルト画像(DEFAULT_OG_IMAGE)を適用します。
  • 404時のメタデータ: 記事データが取得できなかった場合(null)は、即座に notFound() を実行するか、あるいは「ページが見つかりません」というタイトルのメタデータを返して、ユーザーに状況を伝えます。

実装時の注意点とよくある間違い(アンチパターン)

メタデータ実装時の注意点として、generateMetadata はサーバーコンポーネントでのみ動作すること、JSON-LD(構造化データ)とは別物であること、スマホ検索結果を意識したタイトル文字数などが挙げられます。

特に重要なキーワードはタイトルの左側に配置し、30文字以内で内容が伝わるようにすることがCTR向上の鉄則です。

クライアントコンポーネント("use client")での制限

generateMetadata や metadata はサーバー側で実行されるため、"use client" が付いたクライアントコンポーネントのファイルには記述できません。
もしクライアントコンポーネントで構成されたページ(例:インタラクティブなフォーム画面など)にメタデータを設定したい場合は、その親となる page.tsx や layout.tsx(サーバーコンポーネント)側で定義する必要があります。

JSON-LD(構造化データ)との混同に注意

Metadata APIはあくまで <head> 内の <title> や <meta> タグを生成するものです。
Google検索でリッチリザルト(FAQや記事のカルーセルなど)を出すための JSON-LD(構造化データ) は、これとは別に <script type="application/ld+json"> タグとして出力する必要があります。これらは役割が異なるため、混同しないようにしましょう。

metadataBase未設定による相対パスエラー

metadataBase を設定せずに相対パス(例:/images/ogp.png)で画像を指定すると、ビルド時にエラーになったり、OGP画像が表示されなかったりします。
特にVercelなどのホスティングサービスを使う場合は、必ずルートの layout.tsx で metadataBase を設定する癖をつけましょう。

よくある間違いリスト

  1. metadataのpropsにJSXを入れてしまう:
    metadata オブジェクトは純粋なJavaScriptオブジェクトである必要があります。<title>...</title> のようなJSXタグを直接書くことはできません。
  2. openGraphのdescriptionに長文を入れすぎる:
    SNSのタイムラインでは、説明文が長すぎると省略されるだけでなく、スパム判定されるリスクもあります。160文字程度に留めるのが無難です。
  3. generateMetadataの返却値をawaitしてしまう:
    generateMetadata 自体は非同期関数ですが、その戻り値(Metadataオブジェクト)の中でさらに await を使う必要はありません。データ取得部分で await を使い、オブジェクトの構築は同期的に行います。
  4. Twitterカード設定忘れによるOGP崩れ:
    openGraph だけ設定して twitter プロパティを設定し忘れると、Twitter(X)で画像が小さく表示されたり、カードが表示されなかったりすることがあります。必ず両方設定しましょう。

よくある質問(FAQ)

Next.jsのメタデータに関するよくある質問として、head.js との違い、OGP画像が更新されない問題、多言語対応などが挙げられます。

SNSのキャッシュ問題に対しては、画像URLにバージョンパラメータを付与するなどの運用回避策が有効です。

Q以前のHeadコンポーネント(next/head)は使えますか?
A

App Routerでは next/head は非推奨(動作しない場合が多い)であり、Metadata APIへの移行が強く推奨されています。
Pages Routerから移行する場合は、Head コンポーネントの中身を metadata オブジェクトに書き換える作業が必要です。

QSNSでOGP画像が更新されないのはなぜですか?
A

TwitterやLINEなどのSNSは、一度クロールしたOGP画像を強力にキャッシュします。
記事を更新して画像を変えたのに反映されない場合は、各SNSが提供している「Card Validator(デバッガ)」でキャッシュクリアを行うか、画像URLに ?v=2 のようなクエリパラメータを付与して、別のURLとして認識させるテクニックが有効です。

Q多言語対応(i18n)時のメタデータ設定は?
A

generateMetadata 内で params.lang を受け取り、言語ごとに異なるタイトルや説明文を設定します。
また、alternates プロパティを使って hreflang タグを出力し、検索エンジンに言語ごとのURLを伝えることも重要です。

Qrobotsタグでnoindexにするには?
A

metadata オブジェクト内で robots: { index: false, follow: false } と設定することで、そのページを検索結果から除外(noindex)できます。
開発環境や、会員限定ページなどで誤ってインデックスされないように設定しましょう。

まとめ

本記事では、Next.js App Routerにおけるメタデータ実装について解説しました。

  • 基本: 静的ページは metadata、動的ページは generateMetadata を使う。
  • 効率化: fetch のメモ化により、データ取得の重複は気にしなくて良い。
  • 運用: metadataBase でパス解決を楽にし、共通関数でフォーマットを統一する。
  • 注意: クライアントコンポーネントには書けないため、親で定義する。

適切なメタデータ設定は、SEO順位だけでなく、SNSでのシェア率(CTR)にも直結します。conetsの実装例を参考に、管理しやすく効果的なSEO基盤を構築してください。

pick upピックアップ記事一覧

More Informationconetsについてさらに詳しく知る

CONTACT

無料相談のご予約

経営・集客・データ活用のお悩みを、
専門コンサルタントが丁寧にヒアリングし、
「今すぐ取り組むべき一手」を明確にします。
まずはお気軽にご相談ください。

  • 打ち手が散らかっている
  • 集客が安定しない
  • 広告が結果につながらない
  • CRMやデータ管理が複雑
  • 仕組み化が進まない

ご相談は24時間、
オンラインで受け付けています。

LINE相談・オンライン予約・フォームから
お問い合わせください。