Przejdź do treści

Dynamiczna sitemap.xml w Astro z API CMS-a

Jak wygenerować sitemap.xml w Astro z Payload lub Sanity? Praktyczny wzorzec z getStaticPaths, lastmod, sitemap-index i przykładami na example.com.

Maciej Sala

Founder StriveLab

7 min czytaniaOpublikowano 17 lutego 2026 (Aktualizacja 6 lipca 2026)

Szybka odpowiedź: jak wygenerować sitemap.xml w Astro z CMS?

Warto od razu określić stawkę, by było jasne o co toczy się gra. Jeśli nowa treść nie jest szybko odkrywana, tracisz ruch w „prime time", co przy newsach, dynamicznych landingach czy ofertach czasowych może oznaczać utracony przychód. Sitemap nie gwarantuje indeksacji, ale mówi crawlerowi: „te adresy istnieją, te właśnie się zmieniły, warto je sprawdzić". Automatyzacja tego procesu to nie luksus, tylko wymóg każdego projektu, który skaluje content powyżej 10–20 stron.

Architektura: build-time sitemap zamiast SSR

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.

Jakie URL-e powinny trafić do sitemap.xml w Astro

Sitemapa nie jest listą wszystkich adresów, które technicznie istnieją w aplikacji ale to lista URL-i, które chcesz widzieć w wynikach wyszukiwania. W związku z tym, do sitemapy dodawaj tylko adresy, które:

  1. Zwracają HTTP 200.
  2. Nie używają .
  3. Są kanoniczną wersją danej treści.
  4. Nie są przekierowaniem, fallbackiem ani stroną błędu.
  5. Nie są duplikatem parametrów typu ?sort=, ?page= albo ?utm=, mają realną treść i nie są tylko pustym szablonem z CMS-a.

W praktyce filtruj dane już na etapie pobierania z CMS-a: status === 'published', slug istnieje, canonicalUrl nie wskazuje gdzie indziej, a rekord nie jest wersją roboczą. Jeżeli strona ma robots: noindex, nie powinna pojawić się w sitemapie. Jeżeli URL przekierowuje na inny adres, w sitemapie powinien być adres docelowy.

Implementacja sitemap.xml w Astro krok po kroku

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://example.com',
  integrations: [sitemap()],
})

Najważniejszym mechanizmem jest @astrojs/sitemap, który wykrywa strony zbudowane podczas builda, włącznie z tymi wygenerowanymi przez getStaticPaths. Nie musisz ręcznie wpisywać ścieżek do sitemapy, ponieważ wystarczy tylko, ż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
    .filter((post) => post.status === 'published' && post.slug)
    .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, przerwać build przy błędzie 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 ?? 'https://cms.example.com/api'
 
async function getPosts() {
  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)) throw new Error('CMS zwrócił nieprawidłowy format')
 
  return docs.filter((post) => post.status === 'published' && post.slug)
}
 
// Pobieramy raz, na starcie builda, i budujemy mapę lastmod
const docs = await getPosts()
const lastmodMap = new Map(
  docs.map((p) => [`/blog/${p.slug}/`, new Date(p.updatedAt).toISOString()]),
)
 
export default defineConfig({
  site: 'https://example.com',
  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
 
        return item
      },
    }),
  ],
})

To wszystko, czego potrzebuje większość projektów. Jeden build i masz sitemap-index.xml z poprawnymi datami <lastmod> dla każdego opublikowanego artykułu, aktualizowany automatycznie przy każdej publikacji, która wyzwala redeploy.

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

Czasami możesz chcieć pełnej kontroli nad XML-em. Możesz mieć własny podział na pliki, własne reguły, dane prosto z API bez polegania na auto-wykrywaniu stron. W takiej sytuacji musisz stworzyć statyczny endpoint. Poniższy plik możesz wkleić do projektu i podmienić tylko stałe w jego górnej części:

Code
// src/pages/blog-sitemap.xml.ts
import type { APIRoute } from 'astro'
 
const SITE = 'https://example.com'
const CMS_API = import.meta.env.CMS_API_URL ?? 'https://cms.example.com/api'
 
type CmsPost = {
  slug: string
  status: 'draft' | 'published'
  updatedAt: string
}
 
// 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 parametrach zapytania)
const esc = (s: string) =>
  s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
 
export const GET: APIRoute = async () => {
  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)) throw new Error('CMS zwrócił nieprawidłowy format')
 
  const posts = docs.filter((post: CmsPost) => {
    return post.status === 'published' && post.slug
  })
 
  const urls = posts
    .map((post: CmsPost) => {
      const slug = encodeURIComponent(post.slug)
      const loc = esc(`${SITE}/blog/${slug}/`)
      const lastmod = new Date(post.updatedAt).toISOString()
      return `  <url>
    <loc>${loc}</loc>
    <lastmod>${lastmod}</lastmod>
  </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, czyli zwykły plik na CDN. (export const prerender = true ma znaczenie tylko wtedy, gdy projekt działa w trybie server/SSR ponieważ wymusza statyczność tego konkretnego pliku.)

Sitemap.xml a : co naprawdę czyta Google?

<lastmod> musi oznaczać istotną zmianę

<lastmod> to opcjonalny tag, który Google może wziąć pod uwagę, ale tylko wtedy, gdy jest spójny i możliwy do zweryfikowania. Poprawna data aktualizacji mówi: „tu zmieniła się istotna treść, warto wrócić". Nie powinna oznaczać każdego builda, deployu, zmiany stopki albo aktualizacji copyrightu.

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.

priority i changefreq nie są strategią indeksacji

Możesz ustawić priority i changefreq dla porządku albo dla narzędzi, które je analizują, ale Google oficjalnie ignoruje oba tagi. Nie buduj strategii indeksacji wokół wartości 0.8 czy weekly. Większe znaczenie mają: kanoniczny URL, poprawny status HTTP, brak noindex, linkowanie wewnętrzne i wiarygodny lastmod.

Sitemap Index — co zrobić przy tysiącach stron?

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 sitemap.xml w Astro

Weryfikacja w Google Search Console

Po wdrożeniu zgłoś sitemapę w GSC. Wejdź w raport Mapy witryn → dodaj adres https://example.com/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 wtedy wyraźny sygnał, że coś jest nie tak. Rozbicie na pliki tematyczne (sekcja wyżej) sprawia, że od razu wiadomo gdzie.

Dodaj też sitemapę do robots.txt, żeby crawler znalazł ją bez ręcznego zgłoszenia w panelu:

Code
User-agent: *
Allow: /
 
Sitemap: https://example.com/sitemap-index.xml

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

To realne ryzyko, które trzeba obsłużyć w sposób przemyślany, bo jeśli fetch do CMS-a zwróci błąd albo pustą listę w trakcie builda, zaczynają się kłopoty. Masz dwa scenariusze, a każde z nich jest groźne:

  • build się wywala i 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, czyli 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 to 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 wykryje zbudowane strony i wygeneruje statyczny sitemap-index.xml. Sitemapa pomaga wyszukiwarkom odkrywać URL-e, ale nie gwarantuje indeksacji.

Czy sitemapa w Astro jest generowana dynamicznie przy każdym wejściu Googlebota?

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.

Jak ustawić lastmod w sitemapie Astro na podstawie daty z CMS?

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.

Czy priority i changefreq w sitemapie mają sens dla SEO?

Dla Google nie — oficjalnie ignoruje oba tagi. Możesz je ustawiać dla porządku i dla innych narzędzi, ale realny wpływ na odkrywanie i ponowne crawlowanie mają przede wszystkim poprawne, kanoniczne URL-e oraz wiarygodny lastmod.

Co zrobić, gdy mam więcej niż 50 000 stron?

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.

Co się stanie, jeśli API CMS-a padnie podczas builda?

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.

Czy to samo podejście zadziała z Sanity zamiast Payload?

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.

Czy sitemapa gwarantuje indeksację wszystkich stron?

Nie. Sitemapa jest sygnałem odkrywania URL-i, a nie gwarancją indeksacji. Google nadal ocenia jakość strony, kanoniczność, duplikację, dostępność dla crawlera, linkowanie wewnętrzne i dyrektywy typu noindex. Dlatego do sitemapy wrzucaj tylko adresy, które mają zwracać 200, być kanoniczne i nadawać się do indeksowania.

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 rozwijam własne projekty.

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

Skontaktuj się ze mną

Biblioteka wiedzy na temat Astro

Czytaj dalej

Zobacz więcej wpisów
Next.js Sitemap i robots.txt — automatyczna generacja z App Routera

Next.js App Router oferuje natywne API bez zewnętrznych bibliotek do generowania obu plików. W tym artykule pokazuję, jak zrobić to konwencjami app/sitemap.ts i app/robots.ts , kiedy wystarczy natywne rozwiązanie, a kiedy warto sięgnąć po next-sitemap .

Maciej Sala

Maciej Sala

Founder StriveLab

Astro i Headless CMS: Integracja Sanity, Storyblok, Strapi

Markdown w plikach projektu działa sprawnie, dopóki treść piszą osoby, które swobodnie pracują w Git. W sytuacji, kiedy klient chce sam zmienić cennik, a redaktor poprawia nagłówek w piątek po południu, repozytorium przestaje być wygodnym CMS-em. Wtedy najlepszy będzie headless CMS, czyli panel dla redakcji i API dla Astro.

Maciej Sala

Maciej Sala

Founder StriveLab

Jak połączyć Astro z Sanity CMS? Przewodnik po ultra-szybkim blogu

Jeśli budujesz bloga, portfolio albo stronę firmową, gdzie liczy się każdy punkt w Google, połączenie Astro z Sanity jest jednym z najlepszych wyborów , jakie możesz zrobić w 2026 roku. Sanity dostarcza ustrukturyzowaną treść, a Astro zamienia ją w czysty HTML bez zbędnego JavaScriptu. Strona ładuje się, zanim użytkownik zdąży mrugnąć, a Lighthouse 100/100 jest w zasięgu dzięki decyzjom architektonicznym podjętym już na samym początku.

Maciej Sala

Maciej Sala

Founder StriveLab