- Next.js
Next.jsのメタデータとSEO設定|sitemap・OG画像まで実出力つき
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期連続の黒字決算。