C
Next.js/SEO/Lesson 14

Metadata API — generateMetadata で動的 SEO + OG カード

30分·theory
このチャプター
1/2
TypeScript

Metadata API — generateMetadata で動的 SEO + OG カード

💡 なぜ学ぶべきか? — SEO とソーシャルシェアの差を生む部分

🎯 検索結果のタイトル・説明、KakaoTalkやTwitterのシェアカードに表示される画像・テキスト — これらはすべて `` 内のmetaタグによって決まります。
💼 Pages Routerの `` コンポーネントは、App Routerの `metadata` エクスポートとして標準化されました。
動的ページ(ブログ記事など)では、`generateMetadata({ params })` を使うことでページごとに異なるメタデータを自動生成できます。
🔗 ファイルコンベンション — `app/icon.tsx`・`app/opengraph-image.tsx`・`app/twitter-image.tsx` を配置するだけで、自動的にメタデータに紐づけられます。
📈 これを知らないと、すべてのページのシェアカードが同じになってしまいます — 「アマチュア」という印象を与えかねません。
🏢 실무에서는
ブログ記事100本 — 各記事のタイトル・要約・アイキャッチ画像がシェアカードに個別に表示される必要があります。`generateMetadata` の関数一つで100ページ分を自動処理できます。また、検索エンジンが記事タイトルを正確に取得できるため、SEOランキングに直結します。

metadata export · generateMetadata · ファイルコンベンション

1. 静的 metadata export

ts
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'コードマスター — 無料コーディング学習',
  description: '170個の学習コンテンツ、AIコードレビュー、模擬面接',
  keywords: ['コーディング', '学習', 'AI', '面接'],
  openGraph: {
    title: 'コードマスター',
    description: '無料コーディング学習プラットフォーム',
    images: ['/og-image.png'],
  },
  twitter: {
    card: 'summary_large_image',
    images: ['/og-image.png'],
  },
};

各ページ・レイアウトの先頭で export する。自動的に <head> に注入される。

2. generateMetadata — 動的ページ向け

ts
import type { Metadata } from 'next';

export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> {
  const post = await fetch(`https://api/posts/${params.slug}`).then(r => r.json());

  return {
    title: `${post.title} | コードマスターブログ`,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.coverImage, width: 1200, height: 630 }],
    },
  };
}

同じ fetch がページ本文でも呼ばれる場合は自動的に重複排除される — 余分なネットワークコストなし。

3. layout.tsx の title.template — 全ページに自動サフィックス

ts
// app/layout.tsx — ルート
export const metadata: Metadata = {
  title: {
    default: 'コードマスター',
    template: '%s | コードマスター',
  },
};

// app/posts/[slug]/page.tsx
export const metadata: Metadata = {
  title: 'TypeScript完全征服',  // 実際のレンダリング: 'TypeScript完全征服 | コードマスター'
};

4. ファイルコンベンション — メタデータの自動紐づけ

code
app/
├── icon.ico          ← 自動的に <link rel="icon"> になる
├── apple-icon.png    ← Apple Touch Icon
├── opengraph-image.tsx  ← 動的OG画像 (Edge Runtime)
├── twitter-image.png    ← Twitterカード画像
└── robots.txt        ← /robots.txtとして自動公開

opengraph-image.tsx は ImageResponse で動的PNG生成が可能。codemaster40 もすでに [src/app/opengraph-image.tsx](src/app/opengraph-image.tsx) で動的生成を行っている。

5. よく使うフィールドまとめ

ts
export const metadata: Metadata = {
  // 検索エンジン
  title: '...',
  description: '...',
  keywords: ['...'],
  authors: [{ name: 'ホン・ギルドン', url: 'https://...' }],
  robots: { index: true, follow: true },

  // 標準リンク
  alternates: {
    canonical: 'https://example.com/canonical-url',
    languages: { 'ko-KR': '/ko', 'en-US': '/en' },
  },

  // Open Graph (Facebook・KakaoTalk)
  openGraph: {
    type: 'article',
    url: 'https://...',
    title: '...',
    description: '...',
    images: [{ url: '...', width: 1200, height: 630 }],
    siteName: 'コードマスター',
  },

  // Twitter
  twitter: {
    card: 'summary_large_image',
    title: '...',
    description: '...',
    images: ['...'],
    creator: '@handle',
  },
};
💻 🅰️ Pages Router — next/head コンポーネント
// ❌ Pages Router — next/head コンポーネント内で

// 📁 pages/blog/[slug].tsx
import Head from 'next/head';
import type { GetServerSideProps } from 'next';

interface Post { slug: string; title: string; excerpt: string; coverImage: string; }

export const getServerSideProps: GetServerSideProps<{ post: Post }> = async (ctx) => {
  const post = await fetch(`https://api/posts/${ctx.params!.slug}`).then(r => r.json());
  return { props: { post } };
};

export default function BlogPost({ post }: { post: Post }) {
  return (
    <>
      <Head>
        <title>{post.title} | コードマスターブログ</title>
        <meta name="description" content={post.excerpt} />
        <meta property="og:title" content={post.title} />
        <meta property="og:description" content={post.excerpt} />
        <meta property="og:image" content={post.coverImage} />
        <meta name="twitter:card" content="summary_large_image" />
        <meta name="twitter:image" content={post.coverImage} />
      </Head>
      <article>{/* ... */}</article>
    </>
  );
}

// 欠点:
// - ページごとに <Head> を直接記述 — ボイラープレート
// - OG と Twitter が同じ情報で重複 (二重記述)
// - コンポーネントツリー内にあり、親/子の優先順位が分かりにくい
💻 🅱️ App Router — metadata export + generateMetadata
// ✅ App Router — metadata + generateMetadata

import type { Metadata } from 'next';
import { notFound } from 'next/navigation';

interface Post { slug: string; title: string; excerpt: string; coverImage: string; }

// 📁 app/layout.tsx — ルート (すべてのページに継承)
export const metadata: Metadata = {
  title: {
    default: 'コードマスター — 無料コーディング学習',
    template: '%s | コードマスター',  // 子ページが 'X' の場合 'X | コードマスター'
  },
  description: '170個の学習コンテンツ、AIコードレビュー、模擬面接',
  metadataBase: new URL('https://codemaster40.com'),
  openGraph: {
    siteName: 'コードマスター',
    locale: 'ja_JP',
  },
  twitter: {
    card: 'summary_large_image',
  },
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return <html lang="ja"><body>{children}</body></html>;
}

// 📁 app/blog/[slug]/page.tsx — 動的メタデータ
export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> {
  const post: Post | null = await fetch(`https://api/posts/${params.slug}`).then(r => r.json());
  if (!post) return { title: '記事が見つかりません' };

  return {
    title: post.title,                   // 自動的に 'TypeScript 完全攻略 | コードマスター'
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.coverImage, width: 1200, height: 630 }],
      type: 'article',
      publishedTime: '2026-05-26T00:00:00.000Z',
    },
    twitter: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
    alternates: {
      canonical: `https://codemaster40.com/blog/${post.slug}`,
    },
  };
}

export default async function BlogPost({ params }: { params: { slug: string } }) {
  // 同じ fetch — Next.js が自動 dedupe (generateMetadata と一度だけ呼び出し)
  const post = await fetch(`https://api/posts/${params.slug}`).then(r => r.json());
  if (!post) notFound();
  return <article>{post.title}</article>;
}

// 📁 app/opengraph-image.tsx — 動的 OG 画像 (Edge Runtime)
import { ImageResponse } from 'next/og';

export const runtime = 'edge';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

export default function OGImage() {
  return new ImageResponse(
    <div style={{ background: '#000', color: '#fff', fontSize: 80 }}>
      コードマスター
    </div>,
    { ...size },
  );
}

💡 💡 Metadata 実践 5 選

1. metadataBase をルートレイアウトに置く

ts
metadataBase: new URL('https://codemaster40.com')

OG 画像などの相対パスが絶対パスに自動変換される。

2. title.template で一貫したブランディング
ルートに template: '%s | コードマスター' を設定 → 子ページは 'TypeScript 基礎' とだけ書けばよい。

3. generateMetadata の fetch はページの fetch と自動重複排除
同じ URL なら一度だけ呼ばれる。余分なネットワークコストなし。

4. ファイルコンベンション 4 種を活用 — コード 0 行
app/icon.icoapple-icon.pngopengraph-image.pngtwitter-image.png を置くだけで自動的にメタデータが紐づく。

5. OG 画像は 1200×630、Twitter カードは summary_large_image
標準サイズ。検証: https://www.opengraph.xyz/、https://cards-dev.twitter.com/validator

⚡ 実際に試してみよう — generateMetadata 結果シミュレーション

各ページの metadata がどのようにマージされて最終的な head になるかをシミュレーションする。
✏️ JS 코드
📟 コンソール出力
▶ 実行ボタンを押してください
⚠️ ブラウザのサンドボックスで実行 — console.log()のみ対応、alert/fetchは不可

確認クイズ

App Router のルートレイアウトに `title.template = '%s | コードマスター'` があり、子ページが `title: 'Promise<T>'` を設定している場合、実際にレンダリングされる `<title>` は何か?
💡 `title.template` の `%s` が子ページのtitleに置き換えられます。そのため 'Promise<T>' + ' | CodeMaster' = **'Promise<T> | CodeMaster'** となります。子ページがtemplateの影響を受けないようにしたい場合は、`title: { absolute: 'Plain Title' }` と明示的に指定します。これは一貫したブランドサフィックスを自動付与するパターンなので、各子ページではコアとなるキーワードだけを記述すれば済みます。
Metadata API — generateMetadata + OG/Twitter - Next.js