
AI Assisted
この記事は筆者の実装経験をもとに実装コードをベースで執筆し、AIによる校閲・推敲を経て公開しています。
Next.js 16の公式APIを使って、SEOの要となるサイトマップ(sitemap.xml)とrobots.txtを自動生成する方法を解説します。 動的コンテンツへの対応はもちろん、サイトマップの種類別分割やGoogle News対応まで、実務で必要なパターンを網羅しました。
本サイトでは、用途別に4種類のサイトマップを実装しています。
| ファイル | 用途 | 更新頻度 | 対象コンテンツ |
|---|---|---|---|
sitemap-index.xml | 親インデックス | 10分 | 全サイトマップを統合 |
sitemap.xml | メインサイトマップ | 1時間 | 静的ページ、カテゴリ、タグ、スケジュール |
sitemap-posts.xml | 記事サイトマップ | 10分 | 全公開記事 |
sitemap-news.xml | Google News用 | 5分 | 過去2日以内の記事 |
robots.txt
└─ sitemap-index.xml(親)
├─ sitemap.xml(静的・カテゴリ・タグ)
├─ sitemap-posts.xml(記事)
└─ sitemap-news.xml(ニュース)
複数のサイトマップを束ねる「親」となるインデックスファイルです。 Google Search Consoleにはこのファイルを登録します。
// app/sitemap-index.xml/route.ts
import { NextRequest } from 'next/server'
import { getSiteUrl } from '@/lib/utils/url'
export async function GET(request: NextRequest) {
const baseUrl = getSiteUrl()
const currentDate = new Date().toISOString()
const sitemapIndex = `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>${baseUrl}/sitemap.xml</loc>
<lastmod>${currentDate}</lastmod>
</sitemap>
<sitemap>
<loc>${baseUrl}/sitemap-posts.xml</loc>
<lastmod>${currentDate}</lastmod>
</sitemap>
<sitemap>
<loc>${baseUrl}/sitemap-news.xml</loc>
<lastmod>${currentDate}</lastmod>
</sitemap>
</sitemapindex>`
return new Response(sitemapIndex, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, max-age=600, s-maxage=600',
},
})
}
// ISRで10分ごとに再生成
export const revalidate = 600
ポイント:
route.ts) を使用してXMLを直接返すCache-ControlヘッダーでCDNキャッシュも制御app/sitemap.tsを作成すると、/sitemap.xmlが自動生成されます。
静的ページとDB取得が必要な動的ページ(カテゴリ、タグ等)を含めます。
// app/sitemap.ts
import { MetadataRoute } from 'next'
import { CategoryService } from '@/lib/db/services/categoryService'
import { TagService } from '@/lib/db/services/tagService'
import { getSiteUrl } from '@/lib/utils/url'
// Firestoreアクセスのため動的レンダリング
export const dynamic = 'force-dynamic'
export const revalidate = 3600 // 1時間ごとに再検証
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = getSiteUrl()
// 静的ページの定義
const staticPages: MetadataRoute.Sitemap = [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
{
url: `${baseUrl}/posts`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.9,
},
{
url: `${baseUrl}/categories`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.8,
},
{
url: `${baseUrl}/tags`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.8,
},
{
url: `${baseUrl}/about`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.7,
},
{
url: `${baseUrl}/contact`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.7,
},
{
url: `${baseUrl}/privacy`,
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 0.3,
},
{
url: `${baseUrl}/terms`,
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 0.3,
},
]
try {
// DBから動的コンテンツを並列取得
const [categories, tags] = await Promise.all([
new CategoryService().findAll(),
new TagService().findAll(),
])
// カテゴリページ
const categoryPages: MetadataRoute.Sitemap = categories.map((category) => ({
url: `${baseUrl}/categories/${category.slug}`,
lastModified: category.updatedAt?.toDate() || new Date(),
changeFrequency: 'weekly',
priority: 0.6,
}))
// タグページ
const tagPages: MetadataRoute.Sitemap = tags.map((tag) => ({
url: `${baseUrl}/tags/${tag.slug}`,
lastModified: tag.createdAt?.toDate() || new Date(),
changeFrequency: 'weekly',
priority: 0.5,
}))
return [...staticPages, ...categoryPages, ...tagPages]
} catch (error) {
console.error('Error generating sitemap:', error)
// エラー時は静的ページのみ返してXMLエラーを防ぐ
return staticPages
}
}
ポイント:
MetadataRoute.Sitemap型を使用Promise.allでDB取得を並列化してパフォーマンス向上記事数が多い場合、メインサイトマップから分離することで管理しやすくなります。
// app/sitemap-posts.xml/route.ts
import { NextRequest } from 'next/server'
import { PostService } from '@/lib/db/services/postService'
import { getSiteUrl } from '@/lib/utils/url'
export async function GET(request: NextRequest) {
try {
const postService = new PostService()
const posts = await postService.findPublished()
const baseUrl = getSiteUrl()
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${posts.map((post) => ` <url>
<loc>${baseUrl}/posts/${post.id}</loc>
<lastmod>${(post.updatedAt?.toDate() || post.createdAt?.toDate() || new Date()).toISOString()}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>`).join('\n')}
</urlset>`
return new Response(sitemap, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
},
})
} catch (error) {
console.error('Error generating posts sitemap:', error)
// エラー時は空のサイトマップを返す
const emptySitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
</urlset>`
return new Response(emptySitemap, {
headers: { 'Content-Type': 'application/xml' },
status: 500,
})
}
}
// ISRで10分ごとに再生成
export const revalidate = 600
Google Newsに掲載されるためには、専用のNews Sitemapが必要です。 重要:過去2日以内に公開された記事のみを含める必要があります。
// app/sitemap-news.xml/route.ts
import { NextRequest } from 'next/server'
import { PostService } from '@/lib/db/services/postService'
import { getSiteUrl } from '@/lib/utils/url'
/**
* Google News Sitemap
* https://developers.google.com/search/docs/crawling-indexing/sitemaps/news-sitemap
*
* Google Newsは過去2日以内に公開された記事のみを対象とする
*/
export async function GET(request: NextRequest) {
try {
const postService = new PostService()
const allPosts = await postService.findPublished()
const baseUrl = getSiteUrl()
// 過去2日以内の記事のみフィルタ
const twoDaysAgo = new Date()
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2)
const recentPosts = allPosts.filter((post) => {
const publishedAt = post.publishedAt?.toDate() || post.createdAt?.toDate()
return publishedAt && publishedAt >= twoDaysAgo
})
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">
${recentPosts.map((post) => {
const publishedAt = post.publishedAt?.toDate() || post.createdAt?.toDate() || new Date()
const publicationDate = publishedAt.toISOString()
return ` <url>
<loc>${baseUrl}/posts/${post.id}</loc>
<news:news>
<news:publication>
<news:name>nero15.dev</news:name>
<news:language>ja</news:language>
</news:publication>
<news:publication_date>${publicationDate}</news:publication_date>
<news:title>${escapeXml(post.title)}</news:title>
</news:news>
</url>`
}).join('\n')}
</urlset>`
return new Response(sitemap, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, max-age=900, s-maxage=900', // 15分キャッシュ
},
})
} catch (error) {
console.error('Error generating news sitemap:', error)
const emptySitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">
</urlset>`
return new Response(emptySitemap, {
headers: { 'Content-Type': 'application/xml' },
status: 500,
})
}
}
// XML特殊文字をエスケープ
function escapeXml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
// ISRで5分ごとに再生成(ニュースは頻繁に更新)
export const revalidate = 300
Google News Sitemapの要件:
xmlns:news名前空間を宣言news:publicationでサイト名と言語を指定news:publication_dateはISO 8601形式news:titleはXMLエスケープが必要app/robots.tsで、クローラーへの指示とサイトマップの場所を通知します。
// app/robots.ts
import { MetadataRoute } from 'next'
import { getSiteUrl } from '@/lib/utils/url'
export default function robots(): MetadataRoute.Robots {
const baseUrl = getSiteUrl()
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: [
// クロールさせたくないパスを指定
// 例: 管理画面、API、認証ページなど
],
},
// 特定のボット(例: AI学習用クローラー)を拒否する場合
// {
// userAgent: 'GPTBot',
// disallow: '/',
// },
],
// サイトマップインデックスを指定
sitemap: `${baseUrl}/sitemap-index.xml`,
}
}
ポイント:
sitemapには親のインデックスファイルを指定Googleはこれらの値を「ヒント」として扱いますが、サイトの構造を伝えるために設定しておきましょう。
| ページタイプ | changeFrequency | priority | 理由 |
|---|---|---|---|
| トップページ | daily | 1.0 | サイトの顔であり、更新頻度も高いため |
| 記事一覧 | daily | 0.9 | 新着記事への導線として重要 |
| 記事詳細 | weekly | 0.7 | 公開後は頻繁に更新されないため |
| カテゴリ一覧 | weekly | 0.8 | 記事へのナビゲーション |
| カテゴリ詳細 | weekly | 0.6 | 記事一覧ほど頻繁には変わらない |
| タグ | weekly | 0.5 | カテゴリより細分化された分類 |
| 固定ページ | monthly/yearly | 0.3-0.7 | 会社概要や規約など、滅多に変わらない |
サイトマップごとに適切なキャッシュ時間を設定します。
| サイトマップ | revalidate | 理由 |
|---|---|---|
| sitemap-index.xml | 600秒(10分) | 子サイトマップの更新を反映 |
| sitemap.xml | 3600秒(1時間) | カテゴリ・タグは頻繁に変わらない |
| sitemap-posts.xml | 600秒(10分) | 新記事公開に対応 |
| sitemap-news.xml | 300秒(5分) | Google Newsは鮮度が重要 |
// 各ファイルの末尾に設定
export const revalidate = 600 // 秒数
記事数が5万件を超える場合、Next.js公式のgenerateSitemaps関数でIDベースの分割が可能です。
// app/sitemap.ts
import { MetadataRoute } from 'next'
import { PostService } from '@/lib/db/services/postService'
const BASE_URL = 'https://example.com'
const LIMIT = 10000 // 1ファイルあたりのURL数
export async function generateSitemaps() {
const totalPosts = await new PostService().count()
const pageCount = Math.ceil(totalPosts / LIMIT)
// [{ id: 0 }, { id: 1 }, { id: 2 }] のような配列を返す
return Array.from({ length: pageCount }, (_, i) => ({ id: i }))
}
export default async function sitemap({
id,
}: {
id: number
}): Promise<MetadataRoute.Sitemap> {
const offset = id * LIMIT
const posts = await new PostService().findMany({ limit: LIMIT, offset })
return posts.map((post) => ({
url: `${BASE_URL}/posts/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: 'weekly',
priority: 0.7,
}))
}
この実装で /sitemap/0.xml, /sitemap/1.xml ... が自動生成されます。
| 確認項目 | 状態 |
|---|---|
sitemap-index.xml が全サイトマップを参照しているか | |
sitemap.xml が静的ページ・カテゴリ・タグを含むか | |
sitemap-posts.xml が全公開記事を含むか | |
sitemap-news.xml が過去2日以内の記事のみか | |
robots.txt が sitemap-index.xml を指しているか | |
| エラー時にXML構造が壊れないか(try-catch) | |
| URLは全て絶対パス(https://〜)になっているか | |
| Google Search Console に送信したか |
Next.js 16でのサイトマップ運用は、用途別に分割することで管理しやすくなります。
これらを組み合わせることで、SEOに強く、かつメンテナンスしやすいサイトマップシステムが構築できます。
スポーツ×ITの会社でバックエンドエンジニア兼マネージャーとして勤務。インテル関連の情報を中心に、AI・IT技術やサイト運用ノウハウも発信しています。
最終更新: 2025年12月31日
© 2025 nero15.dev. All rights reserved.