Back to posts

AstroでRSSとサイトマップを正しく出す

このサイトのリニューアルで、RSSとサイトマップは「ついでに直すもの」の筆頭でした。旧サイト時代のものが残ってはいたのですが、記事のURL体系が変わるタイミングでそのままにしておくと、フィードだけ古いURLを配り続けたり、サイトマップに存在しないページが載ったりします。表示には出てこない部分なので、後回しにすると静かに壊れるんですよね。

Astroではどちらも公式の仕組みが用意されています。RSSは @astrojs/rss、サイトマップは @astrojs/sitemap。自前でXMLを組み立てる必要はないのですが、前提の設定を1つ外すと生成自体が止まったり、間違ったURLで出続けたりします。この記事では、導入手順と、ハマりどころを先に潰すための設定をまとめます。

前提は astro.config の site だけ

両方に共通する前提が、astro.config.mjs の site です。RSSの各記事リンクも、サイトマップのURLも、ここで指定した絶対URLを土台に組み立てられます。

// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://pastora.jp',
  integrations: [sitemap()],
});

注意 site は https:// から始まる本番URLを書きます。プレビュー環境のURL(*.pages.dev など)をうっかり本番の設定に混ぜると、フィードもサイトマップもプレビュー側のURLで配布されてしまいます。環境ごとに設定ファイルが分かれる構成なら、本番用に正しい値が入っているかを先に確認してください。

site が未設定だと、サイトマップ連携は警告を出して生成をスキップします。ビルド自体は成功するので、「ビルドは通るのにサイトマップだけ無い」という分かりにくい状態になります。

RSS:エンドポイント1本で出す

導入は次の3ステップです。

  1. @astrojs/rss をインストールする(npm install @astrojs/rss。これは連携ではなくヘルパーライブラリなので、integrations への追加は不要)
  2. src/pages/rss.xml.ts を作り、GET 関数から rss() の戻り値を返す
  3. 共通レイアウトの <head> に autodiscovery の <link> タグを入れ、フィードリーダーがサイトURLからフィードを見つけられるようにする

肝心のエンドポイントはこうなります。このサイトの記事スキーマ(title / date / description / slug / draft)に合わせた形です。

// src/pages/rss.xml.ts
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';

export async function GET(context) {
  const posts = await getCollection('blog', ({ data }) => !data.draft);
  const sorted = posts.sort(
    (a, b) => b.data.date.getTime() - a.data.date.getTime(),
  );

  return rss({
    title: 'PASTORA',
    description: 'AI・Astro・Cloudflareまわりの学習メモと、個人サイト運用の記録',
    site: context.site,
    items: sorted.map((post) => ({
      title: post.data.title,
      description: post.data.description,
      pubDate: post.data.date,
      link: post.data.slug,
    })),
    customData: '<language>ja</language>',
  });
}

ポイントは3つあります。

  • 下書き除外は getCollection のフィルタでやる。第2引数の関数で draft: true を落とします。ここを忘れると、公開前の記事タイトルと概要がフィード経由で丸見えになります。一覧ページの出し分けとは別に、フィード側でも必ず弾くのが安全です
  • 並び替えは自分でやる。getCollection の戻り順は日付順とは限りません。フィードリーダーは基本的に pubDate で並べますが、XML内の順序がばらばらだと検証ツールや一部のリーダーで「新着が埋もれる」見え方になります。降順ソートを明示しておく方が無難です
  • link は記事の実URLと完全一致させる。このサイトは frontmatter の slug(/posts/…/)がそのままURLになる設計なので、それを使っています。コレクションの id から機械的に組み立てる書き方も公式ドキュメントにはありますが、URLの決め方がどちらなのかを混ぜると、リダイレクト先や404へのリンクを配ることになります

autodiscovery のタグはこうです。

<link
  rel="alternate"
  type="application/rss+xml"
  title="PASTORA"
  href={new URL('rss.xml', Astro.site)}
/>

pubDate は Date で渡す

pubDate には Date オブジェクトを渡します。@astrojs/rss が RSS 2.0 の形式(RFC 822形式、GMT表記)に変換してくれるので、文字列の整形は自前でやらなくて構いません。

逆に、frontmatter の日付を文字列のまま扱っていると、ここでつまずきます。「2026-04-23」のような文字列をそのまま渡すと、検証で弾かれたり、タイムゾーンの解釈がリーダーごとにずれたりします。対策はスキーマ側で受け止めることです。Content Collections のスキーマで z.coerce.date() を使って Date に寄せておけば、エンドポイント側は安心して getTime() で比較もできます(この話はContent Collectionsの型でfrontmatterの事故を防ぐで扱いました)。

全文配信にするか、要約だけにするか

@astrojs/rss には、記事本文のHTMLを content に入れて全文配信するやり方もあります。公式ドキュメントの方法は、Markdownの body を markdown-it などでレンダリングし、sanitize-html で掃除してから渡す、というものです。やればできます。ただ、このサイトでは要約(title と description のみ)にしました。判断材料はこうです。

観点全文配信要約のみ
読む側の体験リーダー内で完結して快適サイトを開くひと手間がかかる
画像・相対リンク相対パスの画像や内部リンクはそのままでは正しく表示されない影響なし
実装の手間レンダリング+サニタイズの追加実装と、その保守が要るrss() の基本形だけで済む
サイトへの来訪リーダーで読み切る分、減りやすい記事ごとにサイトへ来てもらえる

個人の技術ブログで、コードブロックや装飾の見え方まで含めて読んでほしい記事が中心なら、要約でサイトに来てもらう方が記事の意図どおりになります。逆に、短い更新通知が中心のサイトなら全文配信の快適さが勝ちます。「どちらが正解」ではなく、記事の形で決める話だと思います。

サイトマップ:生成されるのは sitemap-index.xml

@astrojs/sitemap は npx astro add sitemap で入ります。ビルドすると出力フォルダに次の2つが作られます。

  • sitemap-index.xml……サイトマップ本体への目次。Search Console に登録するのはこちら
  • sitemap-0.xml……URLの一覧本体。ページ数が増えると sitemap-1.xml のように分割される(1ファイル上限はデフォルト45,000件)

よくある勘違い 登録先を探して sitemap.xml を開くと404になります。この連携が作るのは sitemap-index.xml です。robots.txt に書くのもインデックスの方のURLです。

User-agent: *
Allow: /
Sitemap: https://pastora.jp/sitemap-index.xml

生成対象は、静的に作られる全ページです。getStaticPaths() で展開される動的ルート(記事ページなど)も含まれます。一方で、SSRでオンデマンド生成される動的ルートは対象外です。このサイトは全ページ静的なので、その心配はありません。

載せたくないページは filter で外す

デフォルトでは全ページが載るので、検索結果に出す意味のないページは filter で除外します。filter には site を含む完全なURLが渡ってくるので、パスの断片で判定する書き方が実用的です。

export default defineConfig({
  site: 'https://pastora.jp',
  integrations: [
    sitemap({
      filter: (page) =>
        !page.includes('/search/') && !page.endsWith('/404.html'),
    }),
  ],
});

このサイトで外しているのは、Pagefindで入れたサイト内検索のページと404ページです。ポイントは「ページ側で noindex を付けたものは、サイトマップからも外す」で、片方だけだと検索エンジンへの信号がちぐはぐになります。noindex のページがサイトマップに載っていると、Search Console で「送信されたURLに noindex が付いています」と報告される、あれです。

なお、changefreq や priority もオプションで付けられますが、Google はこの2つを無視すると公式連携のドキュメントにも明記されています。個人ブログでは無理に付けず、デフォルトのままで十分です。

移行時いちばん怖いのは、フィードURLが変わること

サイトマップは移行でURLが変わっても、Search Console に新しい方を登録し直せば済みます。フィードはそうはいきません。

フィードのURLは購読の宛先そのものです RSSリーダーは登録されたフィードURLを定期的に読みに来ます。そのURLが404になると、リーダー側は「更新が止まったサイト」として扱い、購読が静かに途切れます。移行の前後でフィードURL(このサイトでは /rss.xml)を変えないこと。どうしても変える場合は、旧URLから新URLへ恒久リダイレクト(301)を必ず残してください。

旧サイト側で /feed や /rss など別名でも配っていた場合は、そちらも棚卸ししてリダイレクト対象に入れます。「自分は使っていないから」で消すと、昔から購読してくれている人の分が切れます。本人には見えないところで起きるので、移行チェックリストの中でも優先度は高めです。

つまずきポイント早見表

症状原因直し方
ビルドは成功するのにサイトマップが無いsite 未設定で連携が生成をスキップしたastro.config に本番URLの site を設定する
/sitemap.xml が404生成されるのは sitemap-index.xml登録・リンク先をインデックスの方に直す
RSSに下書き記事が混ざるgetCollection のフィルタ漏れ!data.draft のフィルタを入れる
フィード内の並びが日付順にならない取得順のまま出力している渡す前に date 降順でソートする
RSSのリンクが実URLとずれるtrailingSlash の扱いや id/slug の混用実URLの決め方に合わせ、trailingSlash: "never" のサイトは rss() にも trailingSlash: false を渡す
移行後に購読者側で更新が止まったフィードURLが変わって404になった旧URLを維持するか、301リダイレクトを残す

公開前の確認リスト

ビルド後に、この順で目視します。

  • dist/rss.xml があり、最新記事が先頭に来ている
  • dist/rss.xml に下書き記事のタイトルが含まれていない
  • フィード内のリンクがすべて本番ドメインで、実際に開ける
  • dist/sitemap-index.xml と dist/sitemap-0.xml が生成されている
  • サイトマップから検索ページと404が除外されている
  • robots.txt の Sitemap: がインデックスのURLを指している
  • 旧サイトで使っていたフィードURLが、新サイトでも開ける(または301で転送される)

RSSまわりは、壊れてもサイトの見た目が変わらないので、移行のどさくさで一番置いていかれやすい部分です。だからこそ、フレームワーク側の生成に乗せて、確認だけをチェックリスト化しておく。この形がいちばん長持ちすると思っています。

参考