【Next.js】静動URL構造を包括したXMLサイトマップの自動生成

こんちわ〜、カツサンド大好き男です。
最近、個人として参画させてもらってるスタートアップで、XMLサイトマップを自動生成するチケットを対応したので、XMLサイトマップの作成方法について記事を書きたいと思いますカツ〜。

## 目次
1. XMLサイトマップとは
1. 静的URLのXMLサイトマップの作成
1. Confluentと連携して更新日をbuild時からレコード依存にする
1. 動的URL構造のXMLサイトマップの作成
1. これまでの全部盛りXMLサイトマップ
1. おわりに

## XMLサイトマップとは
まず、はじめにXMLサイトマップってなんぞやって話ですが、XMLサイトマップは、ウェブサイトのページ構造を検索エンジンに伝えるための「地図」のようなものです。

### 構成

構成としては主に以下の4つの要素があり、これらの情報を検索エンジンに提供することでWebサイトのインデックス化を効率化します。
1. URL
    サイト内の各ページのリンク
1. 最終更新日時（lasmod）
    ページが最後に更新された日付（ビルド時やレコードの情報etc）
1. 更新頻度（changefreq）
    ページがどの程度の頻度で変更されるかの目安
1. 優先度（priority）
    サイト内での各ページの重要度を示す数値（通常0.0 ~ 1.0）
    ※ただし、Google側はpriorityを無視すると言ってるので本記事で私が作成するサイトマップには含めません。

### 導入メリット

導入メリットには以下の4つの観点があります。
1. 検索エンジンのクロール効率向上
    サイトマップを提供することで、検索エンジンがすべてのページを漏れなく把握でき、特に以下の場合に有効です。
　・サイト構造が複雑な場合
　・動的に生成されるページが多い場合
　・内部リンクが少なく、新しいページを見つけにくい場合
1. 新しいページや更新内容の早期インデックス化
    サイトマップに記載した lastmod によって、最近変更されたページを検索エンジンが素早く認識します。
1. SEO効果の向上
    検索エンジンがサイトの全体像を正確に理解し、関連性の高いページを効率的にインデックス化できるようになります。
1. 検索エンジンに情報を直接提供可能
    通常、検索エンジンはクローリングによってページを見つけますが、サイトマップを利用すれば明示的に知らせることができます。

### 導入が推奨されるサイト
- 大規模なサイト（数百~数千ページ以上）
- 動的に生成されるページが多いサイト（ECサイトやブログetc）
- 新しいページを頻繁に追加・更新するサイト
- 検索エンジンからのトラフィックを重視しているサイト

### 簡単なイメージ
```xml:xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://example.com/</loc>
    <lastmod>2024-11-06</lastmod>
    <changefreq>daily</changefreq>
    <priority>1.0</priority>
  </url>
  <url>
    <loc>https://example.com/about</loc>
    <lastmod>2024-10-31</lastmod>
    <changefreq>monthly</changefreq>
    <priority>0.8</priority>
  </url>
</urlset>
```

これが、検索エンジンにとってウェブサイトの全体像を理解するための「地図」として機能します。
<xmlタグは、XMLとして正しく認識されるための基本的な宣言です。（おまじない）
urlsetタグは、URLリストを包むタグであり、サイトマップの基本構造を成します。
locは、各ページの具体的なURLを指定するために必要です。

### 作成時の懸念点

ここまででXMlサイトマップについて説明してきましたが、WebサイトのURL構造によっては、XMLサイトマップの作成時にいくつか課題がでてきます。例えば、以下のようなものです。（実際に私が直面した課題）
- URLの数が多すぎると手動で作るのは大変
- 静的なURL構造だけではなく、動的なURL構造もカバーしたい
- ビルド時の更新日ではなく外部サイトの記事の更新日と連携させたい

そこで、これより先は上記の課題に対応したXMLサイトマップの自動生成方法を紹介します。

## 静的URLのXMLサイトマップの作成

next-sitemapは、Next.jsのルーティングシステムを利用して静的ページを自動的に検出します。
Next.jsプロジェクトでは、pagesディレクトリ内のすべてのファイルが静的なページとして認識されます。

```:例
pages/index.js → /
pages/about.hs → /about
```
コードでは、transformメソッド内でpath を受け取り、静的なパス構造を把握することが可能です。
```js:コード例
module.exports = {
  siteUrl: 'サイトurl',
  generateRobotsTxt: true,
  sitemapSize: 7000,
  outDir: './.next',

  transform: async (config, path) => {
    let lastModDate = new Date().toISOString();

    return {
      loc: encodeURI(path),
      changefreq: 'daily',
      lastmod: lastModDate,
    };
  },
};

```

next-sutemap.jsファイルをプロジェクトのルートディレクトリに配置し、ビルド→サイトマップ生成といった流れで、以下2つのXMLサイトマップファイルを生成できます。
```terminal:コマンド
yarn build
yarn next-sitemap --config next-sitemap.js
```

```xml:sitemap.xml
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>サイトurl/sitemap-0.xml</loc></sitemap>
</sitemapindex>
```

```xml:sitemap-0.xml
<?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" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>サイトurl</loc><lastmod>2024-12-10T07:42:19.012Z</lastmod><changefreq>daily</changefreq></url>
<url><loc>トップページurl</loc><lastmod>2024-12-10T21:35:42.261Z</lastmod><changefreq>daily</changefreq></url>
<url><loc>ログインページurl</loc><lastmod>2024-12-10T07:38:03.324Z</lastmod><changefreq>daily</changefreq></url>
</urlset>
```
XMLサイトマップファイルは、１ファイルにつき50,000URLまでという制約があるため、①WebサイトのXMLサイトマップファイル(sitemap-X.xml)と、②サイトマップ自体の管理ファイル(sitemap.xml)にわかれ、100,000URLある場合は、sitemap-0.xml、sitemap-1.xml（①のXの部分の数値が変化）といったようにファイルが生成されていきます。

静的なファイルは、単純にpages/index.jsや、[id].tsxなどのgetStaticPathsで定義されたpathが該当します。(元から定義されたもの)

## Confluentと連携して更新日をbuild時からレコード依存にする

前項では、更新日(lastmod)をビルド時のものにしました。
一方、料理関連のWebサイトを運営している場合で、料理のレシピや情報をcontentfulに格納し、例えば肉じゃがページの情報をcontentfulから取得(連携)している場合、contentfulの肉じゃがレコードを更新するとWebサイトの内容も更新させることができますが、前項のサイトマップのlastmodの生成方法では、contentfulの更新日に合わせることができずビルド時の更新日になってしまいます。
そこで、contentfulのapiを使ってレコードとページを突合させ、サイトマップの更新日をcontentfulの更新日に合わせます。

https://www.contentful.com/developers/docs/references/

```js:next-sitemap.js
const { fetchAllRecipeLastModified } = require('./contentful');

module.exports = {
  siteUrl: 'サイトurl',
  generateRobotsTxt: true,
  sitemapSize: 7000,
  outDir: './.next',


  transform: async (config, path) => {
    const recipe = path.split('/').pop(); // 静的に生成されたURLからレシピ名を抽出
    let lastModDate = new Date().toISOString();

    try {
      const entries = await fetchAllRecipeLastModified(recipe);

      if (entries.length > 0) {
        const latestEntry = entries[0];
        lastModDate = latestEntry.updatedAt; // contentfulの各レシピレコードにある更新日時をセット
      }
    } catch (error) {
      console.error(`Failed to fetch last modified date for ${recipe}:`, error.message);
    }

    return {
      loc: encodeURI(path),
      changefreq: 'daily',
      lastmod: lastModDate,
    };
  },
};
```

contentfulのapiを利用するためのアクセスキーまわり取得や設定は各自でお願いしますね。

```js:contentful.js
//環境変数周りの設定は各々で実施してね
const contentful = require('contentful');

//contentfulのクライアントを作成
const client = contentful.createClient({
  space: CONTENTFUL_SPACE_ID,
  accessToken: CONTENTFUL_ACCESS_TOKEN,
});

async function fetchAllRecipeLastModified(recipeName) {
  const allEntries = [];
  let skip = 0;
  const limit = 100; //1回のリクエストで取得する件数

  while (true) {
    const entries = await client.getEntries({
      content_type: 'recipeArticles',
      'category.slug': recipeName,
      limit,
      skip,
    });

    allEntries.push(...entries.items);

    //ページネーションを考慮
    if (entries.items.length < limit) break;
    skip += limit;
  }

  //取得したエントリーの中から更新日である`updatedAt`情報を抽出
  return allEntries.map(entry => ({
    name: entry.category.name,
    updatedAt: entry.sys.updatedAt,
  }));
}

module.exports = { fetchAllRecipeLastModified };
```
実行結果は例えばこんな感じです。
サイトurlページは、ビルド時のものだけど、肉じゃがページは、そのレシピ記事を管理しているcontentfulのレコードから取得したものになります。

```xml:sitemap-0.xml
<?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" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>サイトurl</loc><lastmod>2024-12-10T07:42:19.012Z</lastmod><changefreq>daily</changefreq></url>
<url><loc>肉じゃがページurl</loc><lastmod>contentfulの更新日</lastmod><changefreq>daily</changefreq></url>
</urlset>
```

contentfulのアクセスキーなどの取り扱いには十分に注意してください。

## 動的URL構造のXMLサイトマップの作成

動的なurl構造に関してもXMLサイトマップを作りたいです。

Next.jsではdynamicpathとして[...ids].tsxで動的なURL構造を表現することができます。
想定される動的urlをビルド時に作成するファイルを用意して、それをサイトマップの作成時に読み込むことで自動で何千何万件もの動的urlをサイトマップに含めることができます。

例えば、ご飯屋さんを紹介するサイトなど、無限にご飯屋さんはありますし、URL構造も複雑なものになると思いますが、この方法であればそれらを包括したサイトマップを自動で生成することができます。

静的なサイトマップを構築するコードに以下のadditionalPathsを定義することで、動的なurl構造にも対応できます。urlパターンは別ファイル(./urls.js)で生成します。
```js:next-sitemap.js
const dynamicPaths = require('./urls.js');

module.exports = {
  siteUrl: 'サイトurl',
  generateRobotsTxt: true,
  sitemapSize: 7000,
  outDir: './.next',
  
  additionalPaths: async config => {
    const paths = dynamicPaths.map(path => ({
      loc: path,
      changefreq: 'daily',
      lastmod: new Date().toISOString(),
    }));
    return paths;
  },

//transformなど静的サイトマップの作成と同じ定義

```
```js:urls.js
// Webサイトで動的に生成されるurl構造に合わせて記述する
// 例えば、ご飯屋さんを絞るためのurl構造パターン
const categories = [
  '和風',
  '中華',
]

const stationes = [
  '東京駅',
  '新橋駅',
]

function generatePaths() {
  const paths = [];
  
  // Pattern 1
  categories.forEach(category => {
    paths.push(`${basePath}/${category}`);
  });
  stationes.forEach(station => {
    paths.push(`${basePath}/${station}`);
  });
  
  // Pattern 2（パスのルールとして、2階層ある場合は、"カテゴリ/駅名"の順にします。逆は許容しません。）
  categories.forEach(category => {
    stationes.forEach(station => paths.push(`${basePath}/${category}/${station}`));
  })

  return paths;
}

const allPaths = generatePaths();
module.exports = allPaths;
```
実行結果

```xml:sitemap-0.xml
<?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" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>サイトurl</loc><lastmod>2024-12-10T07:42:19.012Z</lastmod><changefreq>daily</changefreq></url>
<url><loc>サイトurl/和風</loc><lastmod>2024-12-10T07:42:19.012Z</lastmod><changefreq>daily</changefreq></url>
<url><loc>サイトurl/中華</loc><lastmod>2024-12-10T07:42:19.012Z</lastmod><changefreq>daily</changefreq></url>
<url><loc>サイトurl/東京駅</loc><lastmod>2024-12-10T07:42:19.012Z</lastmod><changefreq>daily</changefreq></url>
<url><loc>サイトurl/新橋駅</loc><lastmod>2024-12-10T07:42:19.012Z</lastmod><changefreq>daily</changefreq></url>
<url><loc>サイトurl/和風/東京駅</loc><lastmod>2024-12-10T07:42:19.012Z</lastmod><changefreq>daily</changefreq></url>
<url><loc>サイトurl/和風/新橋駅</loc><lastmod>2024-12-10T07:42:19.012Z</lastmod><changefreq>daily</changefreq></url>
<url><loc>サイトurl/中華/東京駅</loc><lastmod>2024-12-10T07:42:19.012Z</lastmod><changefreq>daily</changefreq></url>
<url><loc>サイトurl/中華/新橋駅</loc><lastmod>2024-12-10T07:42:19.012Z</lastmod><changefreq>daily</changefreq></url>
</urlset>
```

## これまでの全部盛りXMLサイトマップ

最後に、静動的urlを包括し、かつ、静的urlの更新日はコンテントフルの更新日にするコードを記載します。
contentful.jsとurl.jsの内容は適宜修正（関数名くらい？）してください。

```js:next-sitemap.js
const { fetchAllRestaurantLastModified } = require('./contentful');
const dynamicPaths = require('./urls.js');
module.exports = {
  siteUrl: 'サイトurl',
  generateRobotsTxt: true,
  sitemapSize: 7000,
  outDir: './.next',

  additionalPaths: async config => {
    const paths = dynamicPaths.map(path => ({
      loc: path,
      changefreq: 'daily',
      lastmod: new Date().toISOString(),
    }));
    return paths;
  },

  transform: async (config, path) => {
    const restaurant = path.split('/').pop(); // 静的に生成されたURLからレシピ名を抽出
    let lastModDate = new Date().toISOString();

    try {
      const entries = await fetchAllRestaurantLastModified(restaurant);

      if (entries.length > 0) {
        const latestEntry = entries[0];
        lastModDate = latestEntry.updatedAt; // contentfulの各レシピレコードにある更新日時をセット
      }
    } catch (error) {
      console.error(`Failed to fetch last modified date for ${restaurant}:`, error.message);
    }

    return {
      loc: encodeURI(path),
      changefreq: 'daily',
      lastmod: lastModDate,
    };
  },
};
```



## おわりに
本記事では、XMLサイトマップの説明と、Next.jsにおいて静動URL構造を包括したXMLサイトマップの自動生成方法を示しました。


あ、そういえば、カツサンドとワインって合うの知ってた？