Next.js のメタデータとは、<title> や <meta name="description">、OGP、canonical などの <head> 情報を、metadata オブジェクト・generateMetadata 関数・特別なファイル(sitemap.ts など)で宣言的に出力する仕組みです。 手で <head> を書く必要はなく、ルートレイアウトに共通設定、各ページに個別設定を書けば Next.js がまとめて出力します。

この記事では、Next.js 16.3.6 で SEO に必要な設定を一式そろえたサンプルを実際に next build → next start し、curl と Playwright で取得した <head> の出力、sitemap.xml・robots.txt の中身、日本語フォントで生成した OG 画像の結果をそのまま載せています。検証の中で見つかった「子ページで openGraph を書くと親の設定が消える」「ルートに canonical を書くと全ページが同じ canonical になる」といったつまずきも、直す前と後で紹介します。

Next.js メタデータ SEO の早見表

やりたいこと ファイル・書き方
サイト共通のタイトル形式 ルート layout.tsx の title: { template: '%s | サイト名', default: 'サイト名' }
ページごとの固定タイトル・説明 page.tsx で export const metadata
DB や API の値でタイトルを作る page.tsx で export async function generateMetadata()
OGP などの相対URLを絶対URLに ルート layout.tsx に metadataBase: new URL('https://...')
canonical 各ページで alternates: { canonical: '/path' }
SNS 共有時のタイトル・画像 openGraph / twitter、画像は opengraph-image.tsx
OG 画像をコードで生成 opengraph-image.tsx + ImageResponse(next/og)
OG 画像に日本語 ttf / otf / woff のフォントをローカルから読み込む
robots.txt app/robots.ts
sitemap.xml app/sitemap.ts
5万URLを超えるサイトマップ generateSitemaps() で分割(索引ファイルは自作)
構造化データ(JSON-LD) ページ内に <script type="application/ld+json">
favicon・アイコン app/icon.svg・app/apple-icon.png などを置くだけ
PWA のマニフェスト app/manifest.ts
テーマカラー export const viewport = { themeColor }(metadata ではない)
検索に出したくないページ robots: { index: false }

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Next.jsのメタデータとSEO設定|sitemap・OG画像まで実出力つき」

検証環境とファイル構成

検証環境は Next.js 16.3.6(Turbopack)・React 19.2.8・Node.js 22.22.2・Playwright 1.63.0(Chromium 141) です。OG 画像の日本語フォントには、npm の @expo-google-fonts/noto-sans-jp 0.4.3 に含まれる Noto Sans JP Bold(ttf)を使いました。

サンプルは架空の技術ブログで、app/ に layout.tsx・page.tsx・about/page.tsx・posts/[slug]/page.tsx・posts/[slug]/opengraph-image.tsx・posts/sitemap.ts・opengraph-image.tsx・sitemap.ts・robots.ts・manifest.ts・icon.svg を置きました。next build では /robots.txt・/sitemap.xml・/opengraph-image・/posts/sitemap/0.xml などもルートとして一覧に出ます。

ルートレイアウトに書く共通メタデータ

まずサイト共通の値を定数にします(lib/site.ts に SITE_URL = 'https://example.com' と SITE_NAME = 'サンプル技術ブログ' を export)。

// lib/seo.ts
import type { Metadata } from 'next'
import { SITE_NAME } from '@/lib/site'

// 子ページで openGraph / twitter を上書きしても共通項目が消えないよう、共通部分を定数にしておく
export const baseOpenGraph = {
  siteName: SITE_NAME,
  locale: 'ja_JP',
} satisfies Metadata['openGraph']

export const baseTwitter = {
  card: 'summary_large_image',
} satisfies Metadata['twitter']

lib/seo.ts を作る理由は、後述の「つまずき」で実出力とあわせて説明します。

// app/layout.tsx
import type { Metadata, Viewport } from 'next'
import { SITE_NAME, SITE_URL } from '@/lib/site'
import { baseOpenGraph, baseTwitter } from '@/lib/seo'
import './globals.css'

export const metadata: Metadata = {
  metadataBase: new URL(SITE_URL),
  title: {
    template: `%s | ${SITE_NAME}`,
    default: SITE_NAME,
  },
  description: 'Next.js の実装を、動かした結果つきで解説する技術ブログです。',
  applicationName: SITE_NAME,
  openGraph: { ...baseOpenGraph, type: 'website' },
  twitter: { ...baseTwitter },
  robots: {
    index: true,
    follow: true,
  },
}

export const viewport: Viewport = {
  themeColor: '#0f172a',
}

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

各項目の役割です。

項目 役割 ポイント
metadataBase 相対URLを絶対URLにする基準 設定しないまま相対URLを使うとビルドエラー。OGP 画像や canonical はここを基準に展開される
title.template 子ページのタイトルの形 %s に子ページの title が入る。default は必須
openGraph / twitter SNS 共有時の表示 子で書くと丸ごと置き換わる(後述)
viewport.themeColor ブラウザの UI 色 v14 から metadata ではなく viewport に書く

title.template は「子のセグメント」に適用されます。 同じ階層の page.tsx や、page.tsx に書いた template には効きません。テンプレートを無視したいページは title: { absolute: '...' } と書きます。

固定ページの metadata(実際の head 出力)

// app/about/page.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'このブログについて',
  description: 'サンプル技術ブログの運営方針と、記事の検証環境について説明します。',
  alternates: { canonical: '/about' },
}

export default function Page() {
  return <h1>このブログについて</h1>
}

curl -s localhost:7221/about の <head> から、主な行を抜き出した実出力です(同種の行は省略)。

<meta name="theme-color" content="#0f172a"/>
<title>このブログについて | サンプル技術ブログ</title>
<meta name="description" content="サンプル技術ブログの運営方針と、記事の検証環境について説明します。"/>
<link rel="manifest" href="/manifest.webmanifest"/>
<meta name="robots" content="index, follow"/>
<link rel="canonical" href="https://example.com/about"/>
<meta property="og:title" content="このブログについて | サンプル技術ブログ"/>
<meta property="og:description" content="サンプル技術ブログの運営方針と、記事の検証環境について説明します。"/>
<meta property="og:site_name" content="サンプル技術ブログ"/>
<meta property="og:locale" content="ja_JP"/>
<meta property="og:image" content="https://example.com/opengraph-image?977703d18eb23359"/>
<meta property="og:image:width" content="1200"/>
<meta property="og:image:height" content="630"/>
<meta property="og:type" content="website"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:image" content="https://example.com/opengraph-image?977703d18eb23359"/>
<link rel="icon" href="/icon.svg?icon.0h3ljgptmpclv.svg" sizes="any" type="image/svg+xml"/>

出力から分かることです。

  • og:title と twitter:title、og:description は、ページの title・description から自動で補われました(openGraph にタイトルを書かなくても出る)。
  • ルートの opengraph-image.tsx の画像が、子ページにも自動で引き継がれました。 URL の末尾にはハッシュのクエリ文字列が付きます。
  • og:url は自動では出ません。 必要なら openGraph.url を書きます(記事ページで実施)。

generateMetadata で記事ごとのメタデータを作る

DB から記事を取る想定で、0.8秒かかる取得関数を用意しました。generateMetadata とページ本体で同じ関数を呼ぶので、React の cache で包み、1回の描画で問い合わせが1回になるようにします。

// lib/posts.ts(抜粋)
import { cache } from 'react'

// DB の代わり。0.8 秒かかる問い合わせを想定し、同じ描画中の重複呼び出しは cache でまとめる
export const getPost = cache(async (slug: string) => {
  await new Promise((r) => setTimeout(r, 800))
  return POSTS.find((p) => p.slug === slug) ?? null // POSTS は slug・title・description・publishedAt・updatedAt の配列
})
// app/posts/[slug]/page.tsx
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPost } from '@/lib/posts'
import { SITE_NAME, SITE_URL } from '@/lib/site'
import { baseOpenGraph, baseTwitter } from '@/lib/seo'

// "<" を JSON のエスケープ表記に置き換えて、</script> の混入を防ぐ
const LT_ESCAPED = '\\' + 'u003c'

export async function generateMetadata({ params }: PageProps<'/posts/[slug]'>): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) return { title: '記事が見つかりません', robots: { index: false } }

  return {
    title: post.title,
    description: post.description,
    alternates: { canonical: `/posts/${post.slug}` },
    openGraph: {
      ...baseOpenGraph,
      type: 'article',
      title: post.title,
      description: post.description,
      url: `/posts/${post.slug}`,
      publishedTime: post.publishedAt,
      modifiedTime: post.updatedAt,
    },
    twitter: {
      ...baseTwitter,
      title: post.title,
      description: post.description,
    },
  }
}

export default async function Page({ params }: PageProps<'/posts/[slug]'>) {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) notFound()

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    description: post.description,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    url: `${SITE_URL}/posts/${post.slug}`,
    image: `${SITE_URL}/posts/${post.slug}/opengraph-image`,
    publisher: { '@type': 'Organization', name: SITE_NAME },
  }

  return (
    <article>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, LT_ESCAPED) }}
      />
      <h1>{post.title}</h1>
      <p>{post.description}</p>
    </article>
  )
}

/posts/hello-metadata の <head> の実出力(抜粋)です。

<title>メタデータ入門 | サンプル技術ブログ</title>
<link rel="canonical" href="https://example.com/posts/hello-metadata"/>
<meta property="og:title" content="メタデータ入門"/>
<meta property="og:url" content="https://example.com/posts/hello-metadata"/>
<meta property="og:site_name" content="サンプル技術ブログ"/>
<meta property="og:image" content="https://example.com/posts/hello-metadata/opengraph-image?426594f1ef9c21ff"/>
<meta property="og:image:alt" content="記事のアイキャッチ画像"/>
<meta property="og:type" content="article"/>
<meta property="article:published_time" content="2026-09-01"/>
<meta property="article:modified_time" content="2026-09-20"/>
<meta name="twitter:card" content="summary_large_image"/>

openGraph.title を明示した場合、title.template は適用されず、og:title は「メタデータ入門」だけになりました。 SNS 側でサイト名を別に表示するので、この形で問題ありません。サイト名も付けたいなら og:title に自分で連結します。

出力された JSON-LD は <script type="application/ld+json">{"@context":"https://schema.org","@type":"BlogPosting","headline":"メタデータ入門",...}</script> の形で、日本語もそのまま入りました。

JSON-LD は metadata API ではなく、ページ内に <script> として書くのが公式の方法です。 JSON.stringify の結果の < を置き換えているのは、文字列に </script> が含まれたときに script タグが途中で閉じるのを防ぐためです(公式ドキュメントも同じ対策を紹介しています)。トップページには @type: 'WebSite' の JSON-LD を置きました。

streaming metadata:ボットとブラウザで出力場所が変わる

Next.js 15.2 から、generateMetadata の完了を待たずに本文を先に送り、メタデータを後から <body> の中に流し込む「streaming metadata」が入りました。公式ドキュメントでは次のように説明されています。

  • JavaScript を実行して DOM 全体を見るボット(例:Googlebot)は、<body> に後から入ったメタデータも正しく解釈できることを確認済み
  • JavaScript を実行しない「HTML-limited bots」(例:facebookexternalhit)には、従来どおりメタデータを待ってから <head> に入れて返す
  • ボットの判定は User-Agent で行い、htmlLimitedBots で一覧を上書きできる

実際に確かめるため、generateMetadata だけが2秒かかり、本文はすぐ返すページを作りました。

// app/streaming/[slug]/page.tsx
import type { Metadata } from 'next'

async function getTitleSlowly(slug: string) {
  await new Promise((r) => setTimeout(r, 2000)) // 2秒かかる外部APIを想定
  return `遅いメタデータ: ${slug}`
}

export async function generateMetadata({ params }: PageProps<'/streaming/[slug]'>): Promise<Metadata> {
  const { slug } = await params
  return { title: await getTitleSlowly(slug), description: 'generateMetadata が遅いページの例です。' }
}

export default async function Page({ params }: PageProps<'/streaming/[slug]'>) {
  const { slug } = await params
  return <h1>本文はすぐに表示されます({slug})</h1>
}

User-Agent を変えて curl -A で取得し、<title> が </head> より前か後かと、最初の1バイトが届くまでの時間(TTFB)を測った結果です。

User-Agent <title> の位置 TTFB 全体
Chrome(ブラウザ) BODY 0.36秒 2.40秒
Googlebot/2.1 BODY 0.11秒 2.15秒
facebookexternalhit/1.1 HEAD 2.03秒 2.04秒
Twitterbot/1.0 HEAD 2.02秒 2.02秒
Slackbot-LinkExpanding 1.0 HEAD 2.05秒 2.05秒
bingbot/2.0 HEAD 2.08秒 2.08秒

ブラウザと Googlebot には本文が先に届き(TTFB 約0.1〜0.4秒)、メタデータは後から <body> 内に入りました。SNS のリンクプレビュー用ボットと bingbot には、2秒待ってから <head> 入りで返っています。 ブラウザ向けの出力では、<title> や <meta name="description"> が本文の <h1> より後ろに入っていました。

Playwright(Chromium)で開いて DOM を確認すると、<title> の親要素は BODY のままでも、document.title は「遅いメタデータ: demo | サンプル技術ブログ」になっていました。 ブラウザのタブ表示には影響しません。

判定用の正規表現は next/dist/shared/lib/router/utils/html-bots.js にあり、16.3.6 では facebookexternalhit・Twitterbot・Slackbot・Bingbot・LinkedInBot・Discordbot などが含まれていました。Googlebot そのものは含まれず、Mediapartners-Google のような「-Google」「Google-」付きのクローラーが対象です。

常に head に出したい場合

next.config.ts に htmlLimitedBots: /.*/(すべての User-Agent を HTML-limited 扱い)と書くと、streaming を止められます。実際にブラウザの User-Agent で試すと、<title> は HEAD に入り、TTFB は 2.24 秒に伸びました。 公式も「既定のままで多くの場合は十分」としています。

既定のままで困るのは、独自のクローラーや監視ツールが <head> だけを読む場合くらいです。いちばんの対策は、generateMetadata のデータ取得をキャッシュして速くすることです。

本文が同じデータを await している場合(前述の記事ページ)は、全体がデータ待ちになるため、ブラウザでも <title> は <head> に入りました。 streaming が効くのは「メタデータだけが遅い」ページです。

opengraph-image.tsx で日本語のOG画像を作る

ImageResponse(next/og)は JSX と CSS(flexbox の一部)から PNG を作ります。使えるフォント形式は ttf・otf・woff で、woff2 は使えません。 日本語を出すには日本語フォントを渡す必要があります。

フォント指定なしだとどうなるか

フォントを渡さずに「フォント指定なし ABC」を描画すると、外部通信ができないこの検証環境では、日本語部分が □(いわゆる豆腐)になり、英字だけが表示されました。 実行環境の通信状況に左右されないよう、フォントファイルをプロジェクトに含めておくのが確実です。

フォントを npm から用意する

Google Fonts から直接ダウンロードできない環境でも、npm 経由なら ttf を入手できます。今回は @expo-google-fonts/noto-sans-jp に含まれる ttf をプロジェクトにコピーしました。

npm i -D @expo-google-fonts/noto-sans-jp
mkdir -p assets
cp node_modules/@expo-google-fonts/noto-sans-jp/700Bold/NotoSansJP_700Bold.ttf assets/NotoSansJP-Bold.ttf

@fontsource/noto-sans-jp は woff2・woff が文字範囲ごとに分割されているため、1ファイルで済む ttf のほうが扱いやすいです。

コピーした ttf は約5.2MB(5,468,188バイト)でした。サイズが気になる場合は、使う文字だけに絞ったサブセットフォントを作る方法もあります。

記事ごとのOG画像

// app/posts/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { getPost } from '@/lib/posts'
import { SITE_NAME } from '@/lib/site'

export const alt = '記事のアイキャッチ画像'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

// フォントはリクエストに依存しないので、モジュールの読み込み時に1回だけ読む
const notoSansJpBold = await readFile(join(process.cwd(), 'assets/NotoSansJP-Bold.ttf'))

export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params // Next.js 16 から params は Promise
  const post = await getPost(slug)

  return new ImageResponse(
    (
      <div
        style={{
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'space-between',
          padding: 72,
          background: '#0f172a',
          color: '#f8fafc',
          fontFamily: 'Noto Sans JP',
        }}
      >
        <div style={{ fontSize: 72, lineHeight: 1.3 }}>{post?.title ?? '記事が見つかりません'}</div>
        <div style={{ fontSize: 36, color: '#94a3b8' }}>{SITE_NAME}</div>
      </div>
    ),
    {
      ...size,
      fonts: [{ name: 'Noto Sans JP', data: notoSansJpBold, style: 'normal', weight: 700 }],
    }
  )
}

取得結果です。

$ curl -si localhost:7221/posts/hello-metadata/opengraph-image -o og.png
HTTP/1.1 200 OK
cache-control: public, max-age=0, must-revalidate
content-type: image/png

$ file og.png
og.png: PNG image data, 1200 x 630, 8-bit/color RGBA, non-interlaced

生成された画像(29,545バイト)を開くと、紺の背景に白で「メタデータ入門」、左下に灰色で「サンプル技術ブログ」が、Noto Sans JP の太字で正しく描画されていました。

alt・size・contentType を export すると og:image:alt などが自動で出力されます。 フォントのパスは process.cwd()(プロジェクトルート)基準で書き、読み込みはモジュールのトップレベルで1回にします。子要素が複数ある div には display: 'flex' が必要です。画像ファイルで置く場合は opengraph-image.png が 8MB、twitter-image が 5MB までです。

twitter-image.tsx を置かなかった場合も、上の出力のとおり twitter:image には opengraph-image が使われました。

robots.ts と sitemap.ts(実出力つき)

robots.ts

// app/robots.ts
import type { MetadataRoute } from 'next'
import { SITE_URL } from '@/lib/site'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      { userAgent: '*', allow: '/', disallow: ['/admin/', '/api/'] },
    ],
    sitemap: `${SITE_URL}/sitemap-index.xml`,
  }
}
$ curl -s localhost:7221/robots.txt
User-Agent: *
Allow: /
Disallow: /admin/
Disallow: /api/

Sitemap: https://example.com/sitemap-index.xml

sitemap.ts(固定ページ)

// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { SITE_URL } from '@/lib/site'

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    { url: SITE_URL, lastModified: new Date('2026-09-20'), changeFrequency: 'daily', priority: 1 },
    {
      url: `${SITE_URL}/about`,
      lastModified: new Date('2026-09-01'),
      alternates: { languages: { en: `${SITE_URL}/en/about` } },
    },
  ]
}
<?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://example.com</loc>
<lastmod>2026-09-20T00:00:00.000Z</lastmod>
<changefreq>daily</changefreq>
<priority>1</priority>
</url>
<url>
<loc>https://example.com/about</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/about" />
<lastmod>2026-09-01T00:00:00.000Z</lastmod>
</url>
</urlset>

alternates.languages を書くと xhtml:link の hreflang が出力されます。 Content-Type は application/xml でした。

大量URLは generateSitemaps で分割する

1つのサイトマップに入れられるのは 50,000 URL まで(Google の上限)です。12万件の記事を想定して分割しました。

// app/posts/sitemap.ts
import type { MetadataRoute } from 'next'
import { SITE_URL } from '@/lib/site'

const TOTAL_POSTS = 120_000 // 例:記事が12万件ある想定
const PER_SITEMAP = 50_000  // 1ファイルあたりの上限(Google の上限は 50,000 URL)

export async function generateSitemaps() {
  const count = Math.ceil(TOTAL_POSTS / PER_SITEMAP)
  return Array.from({ length: count }, (_, id) => ({ id }))
}

export default async function sitemap(props: { id: Promise<string> }): Promise<MetadataRoute.Sitemap> {
  const id = Number(await props.id) // Next.js 16 から id は Promise<string>
  const start = id * PER_SITEMAP
  const end = Math.min(start + PER_SITEMAP, TOTAL_POSTS)
  // 実際は DB から start〜end の範囲を取得する
  return Array.from({ length: end - start }, (_, i) => ({
    url: `${SITE_URL}/posts/item-${start + i + 1}`,
    lastModified: new Date('2026-09-01'),
  }))
}

URL の件数を数えた結果です。

posts/sitemap/0.xml: 50000
posts/sitemap/1.xml: 50000
posts/sitemap/2.xml: 20000

Next.js 16 では id が Promise<string> で渡されます。 文字列なので、計算に使う前に Number() で数値にします。そのまま掛け算しても JavaScript が数値に変換しますが、型エラーを避ける意味でも明示するのがおすすめです。

サイトマップ索引(sitemap index)は自作する

検証すると、/posts/sitemap.xml は 404 で、分割ファイルをまとめる索引は自動では作られませんでした。 robots.txt に全ファイルを列挙する方法もありますが、Route Handler で索引を返すと管理が楽です。

// app/sitemap-index.xml/route.ts
import { SITE_URL } from '@/lib/site'
import { generateSitemaps } from '@/app/posts/sitemap'

export async function GET() {
  const ids = await generateSitemaps()
  const urls = [`${SITE_URL}/sitemap.xml`, ...ids.map(({ id }) => `${SITE_URL}/posts/sitemap/${id}.xml`)]
  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.map((u) => `  <sitemap><loc>${u}</loc></sitemap>`).join('\n')}
</sitemapindex>`
  return new Response(xml, { headers: { 'Content-Type': 'application/xml' } })
}
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap><loc>https://example.com/sitemap.xml</loc></sitemap>
  <sitemap><loc>https://example.com/posts/sitemap/0.xml</loc></sitemap>
  <sitemap><loc>https://example.com/posts/sitemap/1.xml</loc></sitemap>
  <sitemap><loc>https://example.com/posts/sitemap/2.xml</loc></sitemap>
</sitemapindex>

manifest.ts とアイコン

app/manifest.ts で MetadataRoute.Manifest 型のオブジェクト(name・short_name・start_url・display・theme_color・icons)を返すと、/manifest.webmanifest として JSON が返り、<head> に <link rel="manifest"> が自動で入りました。アイコンは app/favicon.ico・app/icon.svg・app/apple-icon.png を置くだけで <link rel="icon">(apple-icon は apple-touch-icon)が出力されます。

検証で見つかったつまずき(直す前と後)

1. 子ページで openGraph を書くと、親の siteName や card が消える

メタデータの結合は浅いマージで、openGraph や twitter のような入れ子の項目は、子ページで書くと親の値が丸ごと置き換わります。 最初、記事ページで openGraph: { type: 'article', title, ... } だけを書いたときの実出力がこちらです。

<!-- 直す前:記事ページ -->
<meta property="og:title" content="メタデータ入門"/>
<meta property="og:url" content="https://example.com/posts/hello-metadata"/>
<meta property="og:type" content="article"/>
<meta name="twitter:card" content="summary"/>

og:site_name・og:locale が消え、twitter:card は summary_large_image から summary に戻っていました。 共通部分を lib/seo.ts の定数にして、子ページでも展開するように直した後の出力です。

<!-- 直した後 -->
<meta property="og:site_name" content="サンプル技術ブログ"/>
<meta property="og:locale" content="ja_JP"/>
<meta name="twitter:card" content="summary_large_image"/>

2. ルートの layout に canonical を書くと、全ページが同じ canonical になる

最初はルートの layout.tsx に alternates: { canonical: '/' } を書いていました。すると、canonical を書いていない /streaming/demo の出力が <link rel="canonical" href="https://example.com"/> になり、トップページを指していました。 検索エンジンに「このページはトップの複製」と伝えてしまう形です。

書き方 結果
直す前:ルート layout に canonical: '/' canonical 未設定のページも全部 https://example.com
直した後:トップの page.tsx だけに canonical: '/' トップは https://example.com、未設定ページは canonical なし
// app/page.tsx(直した後)
export const metadata: Metadata = {
  alternates: { canonical: '/' },
}

canonical は各ページ(page.tsx か generateMetadata)で、そのページ自身の URL を書くのが安全です。

3. その他のよくある失敗

症状 原因 対処
相対URLでビルドエラー metadataBase が未設定 ルート layout に metadataBase
子ページのタイトルにサイト名が付かない 同じ階層の layout に template を書いた template は親の layout に書く
OG 画像の日本語が □ になる 日本語フォントを渡していない ttf / otf / woff を fonts に渡す
OG 画像でフォントが読めない woff2 を渡した ttf・otf・woff を使う
sitemap/[id] で計算がずれる 16 から id が Promise の文字列 Number(await props.id)

SEO チェックリスト

項目 確認方法 今回のサンプル
metadataBase を本番URLで設定 og:image が https:// で始まるか ○
全ページに固有の title <title> がページごとに違うか ○(template で統一)
canonical がそのページ自身を指す <link rel="canonical"> ○(修正後)
OG 画像の日本語が表示される 画像を開いて確認 ○
twitter:card が意図どおり summary_large_image か ○(修正後)
robots.txt に sitemap の場所 /robots.txt ○
非公開ページは noindex <meta name="robots"> 記事が無い場合に index: false
HTML-limited bots に <head> で返る curl -A "Twitterbot/1.0" ○
公開前の最終確認 Search Console の URL 検査・リッチリザルトテスト 本番公開後に実施

よくある質問

Q. metadata と generateMetadata はどちらを使えばよいですか? A. 値が固定なら metadata、URL パラメータや DB の値で変わるなら generateMetadata です。同じファイルで両方を export することはできません。

Q. streaming metadata で <title> が <body> に入るのは SEO 上問題ありませんか? A. 公式ドキュメントは、DOM 全体を見るボット(例:Googlebot)で正しく解釈されることを確認済みとしています。JavaScript を実行しない SNS のボットなどには <head> に入れて返す仕組みで、今回の検証でも Twitterbot などには <head> で返りました。

Q. OG 画像の日本語フォントはどこから用意すればよいですか? A. ImageResponse は ttf・otf・woff に対応しています。Noto Sans JP(OFL ライセンス)の ttf を assets/ に置いて readFile で読むのが確実で、npm の @expo-google-fonts/noto-sans-jp からも入手できます。

Q. sitemap.xml の索引ファイルは自動で作られますか? A. 16.3.6 で確認した範囲では作られませんでした(/posts/sitemap.xml は 404)。Route Handler で sitemapindex を返すか、robots.txt に各サイトマップを列挙します。

まとめ

  • ルート layout に metadataBase・title.template・共通 OGP、各ページに title・description・canonical を書く
  • openGraph・twitter は子で書くと丸ごと置き換わるので、共通部分を定数にして展開する
  • canonical はページごとに。ルート layout に書くと全ページがトップを指す
  • streaming metadata はブラウザと Googlebot には body に後から、SNS ボットや bingbot には head で返る(実測で確認)
  • OG 画像の日本語は ttf・otf・woff のフォントを渡す。ローカルに置けば環境に左右されない
  • 5万URL超は generateSitemaps、索引は Route Handler で自作。16 から id は Promise
  • 仕上げは チェックリストと Search Console で確認する

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Next.jsのメタデータとSEO設定|sitemap・OG画像まで実出力つき」

株式会社RJCでは、検索に強いサイトづくりの知見を、実際に動かして確かめながらメンバー同士で共有しています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?