Dynamiczna sitemap.xml w Astro: jak automatycznie indeksować tysiące stron z API CMS-a?

Opublikowano
17 lutego 2026
Aktualizacja
17 czerwca 2026
Czas czytania
7 min czytania

Stawka jest konkretna. Jeśli nowa treść nie trafia do indeksu w kilkanaście minut od publikacji, tracisz ruch w „prime time" — przy newsach, dynamicznych landingach czy ofertach czasowych to wprost utracony przychód. Sitemap to Twój najważniejszy list motywacyjny do Googlebota. Mówi mu: „te adresy istnieją, te są ważne, te właśnie się zmieniły, wróć po nie szybciej". Automatyzacja tego procesu to nie luksus, tylko wymóg każdego projektu, który skaluje content powyżej 10–20 stron.

Architektura rozwiązania

Przepływ danych wygląda tak:

Diagram
Build-time vs request-time: dlaczego sitemapę generujemy podczas builda, a nie w SSR.

Cała decyzja sprowadza się do jednego: budujemy sitemapę w czasie build, a nie w czasie żądania (SSR). To rozróżnienie ma realne konsekwencje dla SEO i wydajności:

  • Generowanie statyczne (, domyślne w Astro) — sitemapa powstaje raz, podczas builda. To zwykły plik XML serwowany z . wchodzi na niego tysiące razy i nigdy nie dotyka Twojej bazy ani API CMS-a. Zero obciążenia, natychmiastowa odpowiedź. To Astro w pełnej krasie: szybkość i lekkość.
  • Generowanie dynamiczne () — sitemapa odpytywałaby CMS przy każdym żądaniu Googlebota. Większe obciążenie, wolniejsza odpowiedź, ryzyko timeoutu na dużych zbiorach i niepotrzebny ruch do API.

Skoro sitemapa zmienia się tylko przy publikacji treści (czyli przy kolejnym deployu/buildzie), nie ma powodu generować jej dynamicznie. Build-time to właściwy wybór — dlatego całą logikę opieramy o getStaticPaths i statyczne endpointy.

Implementacja krok po kroku (kod)

Krok 1: Instalacja @astrojs/sitemap

Oficjalna integracja Astro. Instalacja jednym poleceniem:

Code
npx astro add sitemap

Wymaga ustawionego site w konfiguracji — bez tego integracja nie zbuduje absolutnych URL-i:

Code
// astro.config.mjs
import { defineConfig } from 'astro/config'
import sitemap from '@astrojs/sitemap'
 
export default defineConfig({
  site: 'https://strivelab.pl',
  integrations: [sitemap()],
})

Najważniejszy mechanizm: @astrojs/sitemap automatycznie wykrywa wszystkie strony zbudowane podczas builda — łącznie z tymi wygenerowanymi przez getStaticPaths. Nie musisz ręcznie wpisywać ścieżek do sitemapy. Wystarczy, że Twoje dynamiczne trasy faktycznie powstają na buildzie.

Krok 2: Generowanie podstron z CMS przez getStaticPaths

To jest „ważny szczegół" obsługi tras typu /blog/[slug]. W pliku trasy dynamicznej pobierasz dane z API i zwracasz listę ścieżek. Każda z nich stanie się statyczną stroną — i automatycznie wpadnie do sitemapy.

Code
// src/pages/blog/[slug].astro
---
const CMS_API = import.meta.env.CMS_API_URL // np. https://cms.example.com/api
 
export async function getStaticPaths() {
  const res = await fetch(`${CMS_API}/posts?limit=10000&depth=0`)
  const { docs } = await res.json() // Payload: { docs: [...] }
 
  return docs.map((post) => ({
    params: { slug: post.slug },
    props: { post },
  }))
}
 
const { post } = Astro.props
---
<article>
  <h1>{post.title}</h1>
  <!-- ... -->
</article>

Sanity zamiast fetch na REST użyje zapytania GROQ przez klienta @sanity/client, ale zasada jest identyczna: pobierasz listę dokumentów ze slug i updatedAt, mapujesz na ścieżki.

Po tym kroku @astrojs/sitemap zna już wszystkie adresy /blog/<slug>/. Brakuje mu tylko jednej rzeczy: daty ostatniej modyfikacji.

Krok 3: Mapowanie updatedAt<lastmod> przez serialize

Integracja nie analizuje kodu źródłowego strony, więc sama nie wie, kiedy dany artykuł był aktualizowany. Tę informację wstrzykujemy w hooku serialize, który jest wołany dla każdego wpisu tuż przed zapisem na dysk. Najczystsze podejście: raz pobrać dane z CMS (top-level await w configu) i zbudować mapę ścieżka → updatedAt.

Code
// astro.config.mjs
import { defineConfig } from 'astro/config'
import sitemap from '@astrojs/sitemap'
 
const CMS_API = process.env.CMS_API_URL
 
// Pobieramy raz, na starcie builda, i budujemy mapę lastmod
const { docs } = await fetch(`${CMS_API}/posts?limit=10000&depth=0`).then((r) =>
  r.json(),
)
const lastmodMap = new Map(
  docs.map((p) => [`/blog/${p.slug}/`, new Date(p.updatedAt).toISOString()]),
)
 
export default defineConfig({
  site: 'https://strivelab.pl',
  integrations: [
    sitemap({
      serialize(item) {
        const path = new URL(item.url).pathname
 
        // lastmod z realnej daty aktualizacji w CMS
        const lastmod = lastmodMap.get(path)
        if (lastmod) item.lastmod = lastmod
 
        // priorytety wg typu strony
        if (path === '/') item.priority = 1.0
        else if (path.startsWith('/blog/')) item.priority = 0.8
        else item.priority = 0.5
 
        return item
      },
    }),
  ],
})

To wszystko, czego potrzebuje 90% projektów. Jeden build i masz sitemap-index.xml z poprawnymi datami <lastmod> dla każdego artykułu, aktualizowany automatycznie przy każdej publikacji (która i tak wyzwala redeploy).

Wariant „gotowiec": własny endpoint z pełną kontrolą

Czasem chcesz pełnej kontroli nad XML-em — własny podział na pliki, własne reguły, dane prosto z API bez polegania na auto-wykrywaniu stron. Wtedy tworzysz statyczny endpoint. Ten plik możesz wkleić do projektu i podmienić tylko stałe na górze:

Code
// src/pages/blog-sitemap.xml.ts
import type { APIRoute } from 'astro'
 
const SITE = 'https://strivelab.pl'
const CMS = import.meta.env.CMS_URL
 
// W trybie SSR wymuszamy statyczne generowanie tego pliku,
// żeby Googlebot nie odpytywał CMS-a przy każdym wejściu.
export const prerender = true
 
// Escape znaków, które rozbiłyby XML (np. & w slugu czy parametrach)
const esc = (s: string) =>
  s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
 
export const GET: APIRoute = async () => {
  const { docs } = await fetch(`${CMS_API}/posts?limit=10000&depth=0`).then(
    (r) => r.json(),
  )
 
  const urls = docs
    .map((post: { slug: string; updatedAt: string }) => {
      const loc = `${SITE}/blog/${esc(post.slug)}/`
      const lastmod = new Date(post.updatedAt).toISOString()
      return `  <url>
    <loc>${loc}</loc>
    <lastmod>${lastmod}</lastmod>
    <priority>0.8</priority>
  </url>`
    })
    .join('\n')
 
  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`
 
  return new Response(xml, {
    headers: { 'Content-Type': 'application/xml; charset=utf-8' },
  })
}

W domyślnym trybie statycznym Astro ten endpoint jest renderowany raz, na buildzie — efekt jest taki sam jak przy @astrojs/sitemap: zwykły plik na CDN. (export const prerender = true ma znaczenie tylko wtedy, gdy projekt działa w trybie server/SSR — wymusza tam statyczność tego konkretnego pliku.)

Optymalizacja pod Googlebota

Lastmod is King — ale tylko prawdziwy

<lastmod> to jedyny z trzech opcjonalnych tagów, który Google realnie bierze pod uwagę. Poprawna, świeża data aktualizacji to sygnał: „tu coś się zmieniło, wróć szybciej" — i właśnie ona skraca czas ponownego crawlowania.

Jest jednak haczyk, o którym mało kto pisze: Google ufa lastmod tylko, jeśli jest wiarygodny. Jeśli na każdym buildzie ustawisz lastmod na „teraz" dla wszystkich stron (częsty błąd — branie new Date() zamiast daty z CMS), Google szybko zauważy, że daty kłamią, i zacznie je ignorować. Dlatego w kodzie wyżej lastmod pochodzi wprost z updatedAt w CMS, a nie z czasu builda. To różnica między sygnałem, który działa, a szumem, który Google odfiltrowuje.

Priorytetyzacja — z uczciwym zastrzeżeniem

Pokazałem, jak ustawić priority (strona główna 1.0, artykuły 0.8, kategorie 0.5) w funkcji serialize. Rób to dla porządku i dla innych narzędzi (część wyszukiwarek i crawlerów to czyta), ale bądźmy szczerzy: Google oficjalnie ignoruje priority i changefreq. Potwierdza to nawet dokumentacja @astrojs/sitemap. Nie buduj strategii indeksacji wokół tych tagów — całą realną robotę odwala poprawny lastmod i sama obecność URL-a w sitemapie.

Sitemap Index — co zrobić przy 5000+ stronach

Pojedynczy plik sitemapy ma limity: maksymalnie 50 000 URL-i i 50 MB (Astro domyślnie tnie pliki przy entryLimit: 45000). Przy dużych katalogach produktów czy rozbudowanym blogu i tak dziel sitemapę na mniejsze, tematyczne pliki — nie z konieczności technicznej, ale dlatego, że dużo łatwiej diagnozuje się indeksację w , gdy każdy typ treści ma osobny plik (blog-sitemap.xml, products-sitemap.xml).

W @astrojs/sitemap służy do tego opcja chunks:

Code
sitemap({
  entryLimit: 10000,
  chunks: {
    blog: (item) => {
      if (new URL(item.url).pathname.startsWith('/blog/')) return item
    },
    produkty: (item) => {
      if (new URL(item.url).pathname.startsWith('/produkty/')) return item
    },
  },
})

Powstaną osobne pliki (sitemap-blog-0.xml, sitemap-produkty-0.xml, plus domyślny koszyk dla reszty), wszystkie spięte w sitemap-index.xml. Gdy ruch z bloga spadnie, od razu widzisz w GSC, czy problem dotyczy bloga, czy produktów.

Monitoring i utrzymanie

Weryfikacja w Google Search Console

Po wdrożeniu zgłoś sitemapę w GSC: raport Mapy witryn → dodaj adres https://strivelab.pl/sitemap-index.xml. Po przetworzeniu zobaczysz status (powinien być „Sukces"), datę ostatniego odczytu i liczbę wykrytych adresów. Jeśli liczba „wykrytych" mocno odbiega od realnej liczby treści — masz sygnał, że coś w generowaniu nie zadziałało. Rozbicie na pliki tematyczne (sekcja wyżej) sprawia, że od razu wiadomo gdzie.

Ostrzeżenie: co, jeśli API CMS-a padnie podczas builda?

To realne ryzyko, które trzeba świadomie obsłużyć. Jeśli fetch do CMS-a zwróci błąd albo pustą listę w trakcie builda, masz dwa scenariusze — i oba bywają groźne:

  • build się wywala → nie wdrożysz nic (frustrujące, ale bezpieczne),
  • build przechodzi z pustą listą → wdrażasz serwis z pustą sitemapą i bez podstron bloga, co przy regularnym crawlowaniu może doprowadzić do deindeksacji treści.

Dlatego rekomenduję podejście fail-fast dla danych krytycznych: lepiej, żeby build padł głośno, niż żeby po cichu wdrożył uszkodzony serwis.

Code
async function getPosts() {
  try {
    const res = await fetch(`${CMS_API}/posts?limit=10000&depth=0`)
    if (!res.ok) throw new Error(`CMS odpowiedział statusem ${res.status}`)
    const { docs } = await res.json()
    if (!Array.isArray(docs) || docs.length === 0) {
      throw new Error('CMS zwrócił pustą listę postów — przerywam build')
    }
    return docs
  } catch (err) {
    // Fail-fast: nie wdrażaj serwisu bez treści i bez sitemapy
    console.error('[sitemap] Błąd pobierania danych z CMS:', err)
    throw err
  }
}

Wariant pośredni dla bardzo dużych serwisów: zamiast twardego throw możesz wczytać ostatnią dobrą wersję danych z cache (np. plik JSON commitowany na deployu), żeby chwilowa awaria CMS-a nie blokowała wydania. Wybór zależy od tego, co jest gorsze w Twoim projekcie: zablokowany deploy czy ryzyko nieświeżych danych.

Jeśli budujesz rozwiązanie dla biznesu i zależy Ci na solidnej architekturze — nie tylko stronie, ale całym ekosystemie treści na Astro i headless CMS — sprawdź moją matrycę decyzyjną AI albo napisz do mnie w sprawie wdrożenia. A jeśli serwis już działa, ale indeksacja kuleje, od tego jest audyt techniczny SEO.

Audyt techniczny i optymalizacja pod kątem SEO i GEO.
Audyt techniczny SEO

Często zadawane pytania

Jak zrobić dynamiczną sitemapę w Astro z headless CMS?

Najprościej: zainstaluj @astrojs/sitemap, generuj podstrony przez getStaticPaths (pobierając listę dokumentów z API CMS-a), a w opcji serialize ustaw lastmod na podstawie pola updatedAt. Integracja automatycznie wykryje wszystkie zbudowane strony i wygeneruje statyczny sitemap-index.xml.

Nie, jeśli budujesz ją na etapie build (domyślny tryb statyczny Astro). To wtedy zwykły plik XML serwowany z CDN — Googlebot nie odpytuje Twojej bazy ani CMS-a. Sitemapa aktualizuje się przy kolejnym deployu, który i tak wyzwala publikacja nowej treści.

W funkcji serialize integracji @astrojs/sitemap przypisz item.lastmod z pola updatedAt dokumentu (sformatowanego jako ISO string). Nie używaj new Date() z czasu builda — Google ignoruje lastmod, który zawsze pokazuje „teraz", bo szybko rozpoznaje go jako niewiarygodny.

Dla Google nie — oficjalnie ignoruje oba tagi. Możesz je ustawiać dla porządku i dla innych narzędzi, ale realny wpływ na indeksację mają tylko obecność URL-a w sitemapie i poprawny lastmod.

Pojedyncza sitemapa ma limit 50 000 URL-i i 50 MB. Astro automatycznie tnie pliki (entryLimit, domyślnie 45000) i spina je w sitemap-index.xml. Dodatkowo użyj opcji chunks, by podzielić sitemapę tematycznie (blog, produkty) — to ułatwia diagnostykę indeksacji w Search Console.

Bez zabezpieczenia możesz wdrożyć serwis z pustą sitemapą, co grozi deindeksacją treści. Rekomendowane jest podejście fail-fast: opakuj fetch w try/catch i przerwij build (throw), gdy dane nie przyszły. Dla dużych serwisów alternatywą jest fallback do ostatniej dobrej wersji danych z cache.

Tak. Zmienia się tylko sposób pobierania danych — zamiast REST fetch użyjesz zapytania GROQ przez @sanity/client. Cała reszta (mapowanie na ścieżki w getStaticPaths, lastmod w serialize, podział na pliki) jest identyczna.

O autorze

Maciej Sala

Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwija własne projekty side-projecty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy

Czytaj dalej

Zobacz więcej wpisów