Next.js: SEO & メタデータ API

最終更新:2026-08-26

SEO はウェブサイトにとって無料のトラフィックエンジンです。正しいメタデータにより、Google がサイトをインデックスする速度が 3 倍速くなります。

1. 学習目標



2. コンテンツ運用マネージャーの実話

(1) 課題: ローンチから 3 か月経っても Google は 5 ページしかインデックスしていない

Bob は TaskFlow のコンテンツ運用を担当しています。ブログをローンチして 3 か月、50 記事を執筆しました:

「Google Search Console には、インデックスされているのはわずか 5 ページと表示されています。調査すると、すべてのページの <title> が 'TaskFlow' になっており、meta description もなく、Open Graph 画像もありません。そのため、SNS で共有するとプレーンなリンクとして表示されます。」

問題 影響 定量化
重複タイトル 検索エンジンがページを区別できない 50 件中 5 件のみインデックス
OG 画像なし SNS 共有にプレビュー画像なし 共有クリック率: 0.3%
JSON-LD なし リッチメディアサマリーなし 検索結果に追加情報なし
サイトマップなし Google が深いページをクロールできない インデックス遅延 30 日

(2) Next.js メタデータ API の解決策

generateMetadata() を使用して各ページに一意の SEO タグと JSON-LD 構造化データを生成します。

TS
// app/blog/[slug]/page.tsx — 記事 SEO
import type { Metadata } from 'next'

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)

  return {
    title: `${post.title} - TaskFlow ブログ`,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.ogImage, width: 1200, height: 630 }]
    }
  }
}

(3) 効果

指標 最適化前 最適化後 改善
Google インデックス率 10% 98% 9.8倍
SNS 共有クリック率 0.3% 2.8% 9.3倍
リッチメディア検索結果 ❌ なし ✅ パンくず + 記事カード
ページ発見速度 30 日 < 24h 30倍


3. 静的メタデータと動的メタデータ

(1) 静的エクスポート

TS
// app/about/page.tsx — 静的ページのメタデータ
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'TaskFlow について - チームコラボレーションプラットフォーム',
  description: 'TaskFlow は 10,000 以上のチームの効率的なコラボレーションを支援します。プロジェクト管理、リアルタイムコラボレーション、インテリジェント分析機能を提供します。',
  keywords: ['チームワーク', 'プロジェクト管理', 'TaskFlow', 'SaaS'],
  authors: [{ name: 'TaskFlow チーム', url: 'https://taskflow.io' }]
}

(2) 動的生成

TS
// app/products/[id]/page.tsx — 動的メタデータ
import type { Metadata, ResolvingMetadata } from 'next'

type Props = { params: Promise<{ id: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }> }

export async function generateMetadata({ params, searchParams }: Props, parent: ResolvingMetadata): Promise<Metadata> {
  const { id } = await params
  const product = await fetch(`https://api.taskflow.io/products/${id}`).then(r => r.json())

  return {
    title: `${product.name} - TaskFlow 製品`,
    description: product.description,
    openGraph: {
      title: product.name,
      description: product.description,
      url: `https://taskflow.io/products/${id}`,
      siteName: 'TaskFlow',
      images: [
        {
          url: product.ogImage,
          width: 1200,
          height: 630,
          alt: product.name
        }
      ],
      locale: 'zh_CN',
      type: 'website'
    },
    twitter: {
      card: 'summary_large_image',
      title: product.name,
      description: product.description,
      images: [product.ogImage]
    },
    alternates: {
      canonical: `https://taskflow.io/products/${id}`,
      languages: {
        'en': `https://taskflow.io/en/products/${id}`,
        'ja': `https://taskflow.io/ja/products/${id}`,
        'ar': `https://taskflow.io/ar/products/${id}`
      }
    }
  }
}

(3) メタデータフィールド早見表

フィールド 目的
title ページタイトル / <title> '製品詳細 - TaskFlow'
description 検索エンジンサマリー 'TaskFlow プロジェクト管理ツール...'
openGraph Facebook / LinkedIn 共有 { title, description, images }
twitter X (Twitter) カード { card: 'summary_large_image' }
alternates.canonical 重複コンテンツ防止の正規 URL https://taskflow.io/page
alternates.languages hreflang マルチリンガル { 'en': '...' }
robots クローラー指示 { index: true, follow: true }

▶ サンプル: ブログ記事の完全な SEO

Output:

TEXT 📖 参照専用
Async function executes and returns fetched data.
TS
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'

interface Post {
  title: string
  excerpt: string
  ogImage: string
  publishedAt: string
  author: string
  tags: string[]
}

async function getPost(slug: string): Promise<Post> {
  const res = await fetch(`https://api.taskflow.io/blog/${slug}`, { next: { revalidate: 3600 } })
  return res.json()
}

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)

  return {
    title: `${post.title} | TaskFlow ブログ`,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
      publishedTime: post.publishedAt,
      authors: [post.author],
      images: [{ url: post.ogImage, width: 1200, height: 630 }]
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [post.ogImage]
    },
    keywords: [...post.tags, 'TaskFlow', 'プロジェクト管理'],
    alternates: { canonical: `https://taskflow.io/blog/${slug}` },
    robots: { index: true, follow: true }
  }
}

Output:

TEXT 📖 参照専用
Defines TypeScript type(s): Post.

▶ サンプル: robots メタタグでウェブクローラーを制御

Output:

TEXT 📖 参照専用
Defines TypeScript type(s): Post.
TS
// app/private/dashboard/page.tsx — 検索エンジンによるインデックスを防止
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'ダッシュボード - TaskFlow',
  robots: {
    index: false,        // インデックスしない
    follow: false,       // リンクを追跡しない
    noarchive: true,     // キャッシュしない
    nosnippet: true,     // サマリーを表示しない
    nocache: true        // キャッシュしない
  }
}

export default function DashboardPage() {
  return <h1>プライベートダッシュボード</h1>
}
💻 出力 (HTML head):

HTML
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />

Output:

TEXT 📖 参照専用
Browser renders: "プライベートダッシュボード" heading. Search engines will not index this page — the <meta name="robots" content="noindex, nofollow, noarchive, nosnippet"> tag prevents crawling.


4. JSON-LD 構造化データ

(1) 3 つの一般的なスキーマ

100%
graph TB
    A[JSON-LD 構造化データ] --> B[BreadcrumbList<br/>パンくずナビゲーション]
    A --> C[Article<br/>記事詳細]
    A --> D[FAQPage<br/>よくある質問]

    B --> E[検索結果にパスを表示]
    C --> F[ナレッジパネル + カバー画像]
    D --> G[検索結果に Q&A を直接表示]

    style A fill:#cce5ff
    style B fill:#d4edda
    style C fill:#d4edda
    style D fill:#d4edda
スキーマ ユースケース 検索結果の表示
BreadcrumbList 全ページ パンくずトレイルを表示 (ホーム > 製品 > 詳細)
Article ブログ記事 記事カード (タイトル + サマリー + カバー画像 + 公開日)
FAQPage ヘルプセンター / FAQ 質問と回答のリストを表示 (折りたたみ可能)
Product 製品ページ 価格 + 在庫 + 星評価

(2) JSON-LD 注入関数

TS
// lib/jsonld.ts — JSON-LD 生成ツール
export function breadcrumbJsonld(items: { name: string; url: string }[]) {
  return {
    '@context': 'https://schema.org',
    '@type': 'BreadcrumbList',
    itemListElement: items.map((item, index) => ({
      '@type': 'ListItem',
      position: index + 1,
      name: item.name,
      item: item.url
    }))
  }
}

export function articleJsonld(post: {
  title: string
  excerpt: string
  url: string
  ogImage: string
  publishedAt: string
  author: string
}) {
  return {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    description: post.excerpt,
    image: post.ogImage,
    datePublished: post.publishedAt,
    author: { '@type': 'Person', name: post.author },
    publisher: { '@type': 'Organization', name: 'TaskFlow', logo: 'https://taskflow.io/logo.png' },
    mainEntityOfPage: { '@type': 'WebPage', '@id': post.url }
  }
}

▶ サンプル: 記事ページ JSON-LD + パンくず

Output:

TEXT 📖 参照専用
TypeScript module executes successfully.
TSX
// app/blog/[slug]/page.tsx — 完全な SEO + JSON-LD
import { breadcrumbJsonld, articleJsonld } from '@/lib/jsonld'

export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await getPost(slug)
  const url = `https://taskflow.io/blog/${slug}`

  const breadcrumb = breadcrumbJsonld([
    { name: 'ホーム', url: 'https://taskflow.io' },
    { name: 'ブログ', url: 'https://taskflow.io/blog' },
    { name: post.title, url }
  ])

  const article = articleJsonld({
    title: post.title,
    excerpt: post.excerpt,
    url,
    ogImage: post.ogImage,
    publishedAt: post.publishedAt,
    author: post.author
  })

  return (
    <>
      {/* JSON-LD 注入 */}
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumb) }}
      />
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(article) }}
      />

      <article>
        <h1>{post.title}</h1>
        <p>{post.excerpt}</p>
        <div>{post.content}</div>
      </article>
    </>
  )
}

Output:

TEXT 📖 参照専用
Renders the ▶ サンプル: 記事ページ JSON-LD + パンくず component UI as described in the section.
💡 ヒント: Google Search Console には「リッチリザルトテスト」ツールがあり、JSON-LD が正しく解析されているか検証できます。



5. サイトマップと robots.txt

(1) 動的サイトマップ生成

TS
// app/sitemap.ts — 動的サイトマップ(全ページを自動的に含む)
import type { MetadataRoute } from 'next'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const baseUrl = 'https://taskflow.io'

  // 静的ページ
  const staticPages = [
    { url: baseUrl, lastModified: new Date(), changeFrequency: 'monthly' as const, priority: 1.0 },
    { url: `${baseUrl}/about`, lastModified: new Date(), changeFrequency: 'monthly' as const, priority: 0.8 },
    { url: `${baseUrl}/blog`, lastModified: new Date(), changeFrequency: 'weekly' as const, priority: 0.9 },
    { url: `${baseUrl}/pricing`, lastModified: new Date(), changeFrequency: 'monthly' as const, priority: 0.8 },
    { url: `${baseUrl}/contact`, lastModified: new Date(), changeFrequency: 'yearly' as const, priority: 0.5 }
  ]

  // 動的ブログ記事(API から)
  const posts = await fetch('https://api.taskflow.io/blog/posts').then(r => r.json())

  const blogPages = posts.map((post: { slug: string; updatedAt: string }) => ({
    url: `${baseUrl}/blog/${post.slug}`,
    lastModified: new Date(post.updatedAt),
    changeFrequency: 'weekly' as const,
    priority: 0.7
  }))

  // マルチリンガルページ
  const locales = ['en', 'ja', 'ar']
  const localizedPages = locales.flatMap((locale) =>
    staticPages.map((page) => ({
      url: `${baseUrl}/${locale}${page.url.replace(baseUrl, '')}`,
      lastModified: page.lastModified,
      changeFrequency: page.changeFrequency,
      priority: page.priority * 0.9,
      alternates: {
        languages: Object.fromEntries(
          locales.map((l) => [l, `${baseUrl}/${l}${page.url.replace(baseUrl, '')}`])
        )
      }
    }))
  )

  return [...staticPages, ...blogPages, ...localizedPages]
}

(2) robots.txt

TS
// app/robots.ts
import type { MetadataRoute } from 'next'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: '*',
        allow: '/',
        disallow: ['/api/', '/admin/', '/_next/', '/dashboard']
      },
      {
        userAgent: 'GPTBot',
        disallow: '/'
      }
    ],
    sitemap: 'https://taskflow.io/sitemap.xml'
  }
}

▶ サンプル: マルチリンガルサイトマップの検証

TEXT 📖 参照専用
# 生成された sitemap.xml の抜粋
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
        xmlns:xhtml="http://www.w3.org/1999/xhtml">
  <url>
    <loc>https://taskflow.io/about</loc>
    <lastmod>2026-07-06</lastmod>
    <changefreq>monthly</changefreq>
    <priority>0.8</priority>
    <xhtml:link rel="alternate" hreflang="en" href="https://taskflow.io/en/about"/>
    <xhtml:link rel="alternate" hreflang="ja" href="https://taskflow.io/ja/about"/>
    <xhtml:link rel="alternate" hreflang="ar" href="https://taskflow.io/ar/about"/>
  </url>
</urlset>


6. 動的 OG 画像の生成

(1) @vercel/og アーキテクチャ

100%
graph LR
    A[SNS でリンクを共有] --> B[ウェブクローラーが OG 画像をリクエスト]
    B --> C[@vercel/og Edge Function]
    C --> D[Satori + React]
    D --> E[JSX を PNG としてレンダリング]
    E --> F[1200×630 OG 画像]
    F --> G[Facebook / X / LinkedIn で表示]

    style C fill:#cce5ff
    style D fill:#d4edda
    style E fill:#fff3cd
ライブラリ 機能 説明
@vercel/og OG 画像生成 Edge Function サーバーオーバーヘッドゼロ
Satori JSX → SVG 変換 1 ms 未満
resvg-wasm SVG → PNG レンダリング ~5ms

(2) インストールと基本例

BASH
npm install @vercel/og
TSX
// app/og/route.tsx — OG 画像生成 API
import { ImageResponse } from '@vercel/og'

export const runtime = 'edge'

export async function GET() {
  return new ImageResponse(
    (
      <div style={{
        width: '100%',
        height: '100%',
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'center',
        justifyContent: 'center',
        background: 'linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)',
        color: 'white',
        fontSize: 60,
        fontWeight: 700,
        padding: 40
      }}>
        <h1>TaskFlow</h1>
        <p style={{ fontSize: 32, opacity: 0.9 }}>チームコラボレーションプラットフォーム</p>
      </div>
    ),
    { width: 1200, height: 630 }
  )
}

▶ サンプル: 動的記事 OG 画像

Output:

TEXT 📖 参照専用
Renders the GET component UI.
TSX
// app/blog/[slug]/og/route.tsx — 特集記事 OG 画像
import { ImageResponse } from '@vercel/og'

export const runtime = 'edge'

export async function GET(req: Request, { params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params

  // 記事情報を取得
  const post = await fetch(`https://api.taskflow.io/blog/${slug}`).then(r => r.json())

  return new ImageResponse(
    (
      <div style={{
        width: 1200,
        height: 630,
        display: 'flex',
        flexDirection: 'column',
        justifyContent: 'center',
        padding: 60,
        background: 'linear-gradient(135deg, #1e1b4b 0%, #4f46e5 100%)',
        color: 'white'
      }}>
        {/* タグ */}
        <div style={{ display: 'flex', gap: 8 }}>
          {post.tags?.slice(0, 3).map((tag: string) => (
            <span key={tag} style={{
              padding: '4px 12px',
              borderRadius: 20,
              background: 'rgba(255,255,255,0.2)',
              fontSize: 18
            }}>{tag}</span>
          ))}
        </div>

        {/* タイトル */}
        <h1 style={{ fontSize: 52, margin: '20px 0', lineHeight: 1.2 }}>
          {post.title}
        </h1>

        {/* 著者 + 日付 */}
        <div style={{ display: 'flex', gap: 16, fontSize: 22, opacity: 0.8 }}>
          <span>{post.author}</span>
          <span>{new Date(post.publishedAt).toLocaleDateString('zh-CN')}</span>
        </div>

        {/* ロゴ */}
        <div style={{ position: 'absolute', bottom: 40, right: 60, fontSize: 28, fontWeight: 'bold' }}>
          TaskFlow ブログ
        </div>
      </div>
    ),
    { width: 1200, height: 630 }
  )
}

Output:

TEXT 📖 参照専用
Fetches data server-side and renders a list of items from post.
Visible content: ))} | TaskFlow ブログ
💡 ヒント: OG 画像ルートハンドラーは /blog/[slug]/og/route.tsx にあり、最終的なリンクは https://taskflow.io/blog/nextjs-seo-guide/og です。このアドレスを generateMetadata で参照するだけです。



7. 完全なサンプル: マルチリンガル SEO + OG 画像の総合実装

TSX
// app/layout.tsx — ルートレイアウト(デフォルト)SEO + マルチリンガル
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: { template: '%s | TaskFlow', default: 'TaskFlow - チームコラボレーションプラットフォーム' },
  description: 'TaskFlow は世界中の 10,000 以上のチームの効率的なコラボレーションを支援します。プロジェクト管理、リアルタイムコラボレーション、AI インテリジェント分析を提供します。',
  openGraph: {
    siteName: 'TaskFlow',
    type: 'website',
    locale: 'zh_CN',
    images: [{ url: 'https://taskflow.io/og-default.png', width: 1200, height: 630 }]
  },
  twitter: { card: 'summary_large_image', site: '@taskflow' },
  robots: { index: true, follow: true },
  alternates: {
    canonical: 'https://taskflow.io',
    languages: {
      'en': 'https://taskflow.io/en',
      'ja': 'https://taskflow.io/ja',
      'ar': 'https://taskflow.io/ar'
    }
  }
}

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh">
      <body>{children}</body>
    </html>
  )
}
TS
// app/sitemap.ts — 完全なサイトマップ
import type { MetadataRoute } from 'next'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const baseUrl = 'https://taskflow.io'
  const locales = ['zh', 'en', 'ja', 'ar'] as const
  
  const pages = ['', '/about', '/blog', '/pricing', '/contact']
  
  return pages.flatMap((page) =>
    locales.map((locale) => ({
      url: `${baseUrl}/${locale}${page}`,
      lastModified: new Date(),
      changeFrequency: 'monthly' as const,
      priority: page === '' ? 1.0 : 0.8,
      alternates: {
        languages: Object.fromEntries(locales.map((l) => [l, `${baseUrl}/${l}${page}`]))
      }
    }))
  )
}
TS
// app/robots.ts
import type { MetadataRoute } from 'next'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: '*', allow: '/', disallow: ['/api/', '/admin/', '/dashboard'] },
    sitemap: 'https://taskflow.io/sitemap.xml'
  }
}
TSX
// app/blog/[slug]/page.tsx — 記事ページがすべての SEO を統合
import type { Metadata } from 'next'

type Props = { params: Promise<{ slug: string }> }

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const post = await fetch(`https://api.taskflow.io/blog/${slug}`).then(r => r.json())

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
      publishedTime: post.publishedAt,
      authors: [post.author],
      images: [{ url: `https://taskflow.io/blog/${slug}/og`, width: 1200, height: 630 }]
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [`https://taskflow.io/blog/${slug}/og`]
    },
    alternates: {
      canonical: `https://taskflow.io/blog/${slug}`,
      languages: {
        en: `https://taskflow.io/en/blog/${slug}`,
        ja: `https://taskflow.io/ja/blog/${slug}`,
        ar: `https://taskflow.io/ar/blog/${slug}`
      }
    }
  }
}

export default async function BlogPostPage({ params }: Props) {
  const { slug } = await params

  return (
    <article>
      {/* JSON-LD 構造化データ */}
      <script type="application/ld+json" dangerouslySetInnerHTML={{
        __html: JSON.stringify({
          '@context': 'https://schema.org',
          '@type': 'BreadcrumbList',
          itemListElement: [
            { '@type': 'ListItem', position: 1, name: 'ホーム', item: 'https://taskflow.io' },
            { '@type': 'ListItem', position: 2, name: 'ブログ', item: 'https://taskflow.io/blog' },
            { '@type': 'ListItem', position: 3, name: slug }
          ]
        })
      }} />

      <h1>記事タイトル</h1>
      <div>記事内容...</div>
    </article>
  )
}

❓ よくある質問

Q generateMetadata()metadata のエクスポートの違いは何ですか?
A metadata は静的エクスポートで、静的ルート(/about など)に使用されます。generateMetadata() は非同期関数で、params / searchParams に基づいて動的にメタデータを生成し、動的ルート(/blog/[slug] など)に使用されます。この 2 つを同時に使用することはできません。
Q JSON-LD とメタタグの関係は?
A メタタグ(<title> / <meta name="description">)は基本的な SEO タグです。JSON-LD(<script type="application/ld+json">)は構造化データで、Google がリッチスニペット(パンくず、星評価、折りたたみ可能な FAQ)を表示できるようにします。この 2 つは補完的であり、両方を実装する必要があります。
Q 動的 OG 画像の生成はサーバー負荷を増加させますか?
A @vercel/og は Edge Runtime(エッジコンピューティング)で実行され、各画像の生成には ~5 ms しかかからず、ほぼゼロのオーバーヘッドです。本番環境では、CDN キャッシング(Cache-Control: public, max-age=31536000, immutable)を有効にすることをお勧めします。
Q hreflang タグはどこに設定すべきですか?
A generateMetadata()alternates.languages で設定します。Next.js は自動的に <link rel="alternate" hreflang="en" href="..."> をページの <head> に注入します。サイトマップにも対応する xhtml:link タグを含める必要があります。
Q サイトマップの changeFrequencypriority フィールドは Google のランキングに影響しますか?
A Google は公式にこの 2 つのフィールドを無視すると表明しています。Bing や Yandex などの検索エンジンには引き続き参考価値があります。サイトマップにはすべてのページを含め、lastModified フィールドを正確にすることが推奨されます。これはクローラーが再クロールするかどうかを判断する上で重要です。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐): 静的ルート /aboutmetadata エクスポートを設定し、title、description、Open Graph、Twitter カードを含めてください。

  2. 応用問題 (⭐⭐): 記事詳細ページの完全な SEO を実装してください: generateMetadata() でタイトル、説明、OG タグ、Twitter カードを動的に生成し、Article JSON-LD 構造化データを注入し、サイトマップにすべての記事 URL をリストアップします。

  3. 発展問題 (⭐⭐⭐): 完全な OG 画像生成システムを構築してください: @vercel/og で各ブログ記事に 1200×630 の共有画像(タイトル、著者、タグを含む)を生成し、generateMetadata() で画像 URL を参照し、最後に Google リッチリザルトテストで JSON-LD と OG タグの正しさを検証します。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%