@casoon/astro-sitemap
v0.2.2
Published
Astro integration that generates sitemap.xml at build time — supports dynamic sources, pattern-based rules, i18n hreflang, serialize hook, and sitemap-index chunking.
Downloads
1,452
Maintainers
Readme
@casoon/astro-sitemap
Astro integration that generates sitemap.xml at build time.
- Discovers pages via Astro's build pipeline — no
dist/scanning - Dynamic URL sources (content collections, CMS, APIs)
- Pattern-based
priorityandchangefreqrules - Per-item
serializehook for fine-grained control - i18n
hreflanglink generation - Automatic
sitemap-index.xmlchunking for large sites - Built-in audit with warnings for empty sitemaps, duplicates, and invalid values
Requirements: Astro 6+, Node 18+
Installation
npm install @casoon/astro-sitemapQuick Start
// astro.config.mjs
import { defineConfig } from 'astro/config';
import astroSitemap from '@casoon/astro-sitemap';
export default defineConfig({
site: 'https://example.com',
integrations: [astroSitemap()],
});This generates dist/sitemap.xml containing all pages discovered from the build.
Configuration
astroSitemap({
// all options are optional
siteUrl: 'https://example.com',
sources: [...],
exclude: [...],
filter: (url) => true,
priority: [...],
changefreq: [...],
serialize: async (entry) => entry,
i18n: { defaultLocale: 'en', locales: { en: 'en', de: 'de-DE' } },
output: { mode: 'single', maxUrls: 50_000, filename: 'sitemap.xml' },
audit: { warnOnEmpty: true, errorOnDuplicates: false },
debug: false,
})siteUrl
siteUrl?: stringBase URL of your site. Auto-detected from site in astro.config if omitted.
base (Astro config — not a plugin option)
If your site is deployed to a subdirectory, set base in astro.config. The plugin reads it automatically — no plugin configuration needed.
// astro.config.mjs
export default defineConfig({
site: 'https://example.com',
base: '/my-app/', // sitemap URLs become https://example.com/my-app/...
integrations: [astroSitemap()],
});Relative paths in sources entries are also prefixed with the base automatically.
sources
sources?: Array<() => Promise<SitemapEntry[]>>Async functions that return additional URL entries. Use for dynamic content not generated as static pages.
astroSitemap({
sources: [
async () => {
const { getCollection } = await import('astro:content');
const posts = await getCollection('blog');
return posts
.filter((p) => !p.data.draft)
.map((p) => ({
loc: `/blog/${p.id}/`,
lastmod: p.data.updatedAt ?? p.data.date,
priority: 0.7,
}));
},
],
})SitemapEntry fields:
| Field | Type | Description |
|---|---|---|
| loc | string | Full URL or root-relative path (e.g. /blog/post/) |
| lastmod | string | ISO date string (defaults to today) |
| priority | number | 0.0–1.0 (defaults to depth-based rule) |
| changefreq | Changefreq | Defaults to built-in pattern rules |
Source entries are merged with static pages. On duplicate loc, the last entry wins — sources listed later override earlier sources and scanned pages.
Draft pages: Always filter drafts in the source function — the plugin only sees URLs, not frontmatter. For
src/pages/*.mdfiles withdraft: true, Astro skips building them automatically (they never reach the sitemap). For content collections you must filter explicitly:getCollection('blog', ({ data }) => !data.draft)
exclude
exclude?: Array<string | RegExp>URL patterns to exclude from the sitemap. String patterns match if the path equals or starts with the value.
astroSitemap({
exclude: ['/intern/', /\/preview\//],
})Built-in exclusions always apply regardless of this option:
/sitemap*.xml, /robots.txt, /llms.txt, /rss.xml, /feed.xml, /404, /500, /_*, /api/*, /landing/*, /drafts/*
filter
filter?: (url: string) => booleanFunction-based URL filter. Receives the full absolute URL. Return false to exclude. Applied after exclude patterns.
astroSitemap({
filter: (url) => !url.includes('/internal/'),
})priority
priority?: Array<{ pattern: string | RegExp; priority: number }>Custom priority rules evaluated before built-in defaults. First matching rule wins.
astroSitemap({
priority: [
{ pattern: '/produkte/', priority: 0.9 },
{ pattern: /\/blog\/\d{4}\//, priority: 0.6 },
],
})Gotcha — targeting the homepage: The string
'/'matches every URL (all paths start with/), so it acts as a catch-all, not a homepage selector. Use the RegExp/^\/$/to match only the root path:// Wrong — sets priority 0.9 on ALL pages: { pattern: '/', priority: 0.9 } // Correct — sets priority 0.9 on the homepage only: { pattern: /^\/$/, priority: 0.9 }The built-in defaults already assign
/a priority of 1.0, so you only need this when overriding the homepage to a different value.
Built-in depth-based defaults (applied when no rule matches):
| Path | Priority |
|---|---|
| / | 1.0 |
| Depth 1 (e.g. /about/) | 0.9 |
| Depth 2 (e.g. /blog/post/) | 0.8 |
| Depth 3+ | 0.7 |
changefreq
changefreq?: Array<{ pattern: string | RegExp; changefreq: Changefreq }>Custom changefreq rules, evaluated before built-in defaults. First matching rule wins.
astroSitemap({
changefreq: [
{ pattern: '/produkte/', changefreq: 'daily' },
{ pattern: '/impressum/', changefreq: 'yearly' },
],
})Built-in defaults: / and /blog|artikel|news|posts|updates/ → weekly, everything else → monthly.
serialize
serialize?: (entry: ResolvedSitemapEntry) => ResolvedSitemapEntry | undefined | Promise<...>Called for every resolved entry after deduplication. Return a modified entry to keep it, or undefined to drop it from the sitemap.
astroSitemap({
serialize: async (entry) => {
// Drop preview pages
if (entry.loc.includes('/preview/')) return undefined;
// Override lastmod from a CMS
const lastmod = await fetchLastmod(entry.loc);
return { ...entry, lastmod };
},
})ResolvedSitemapEntry fields: loc, lastmod, priority, changefreq, links (hreflang, set by i18n).
i18n
i18n?: {
defaultLocale: string;
locales: Record<string, string>; // locale prefix → hreflang value
}Enables <xhtml:link rel="alternate" hreflang="..."> generation for multi-language sites. Pages are grouped by their path without the locale prefix; each page gets alternate links for all sibling locale variants.
astroSitemap({
i18n: {
defaultLocale: 'en',
locales: {
en: 'en',
de: 'de-DE',
fr: 'fr-FR',
},
},
})Given pages /en/about/ and /de/about/, the output for /en/about/ will include:
<url>
<loc>https://example.com/en/about/</loc>
...
<xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/about/"/>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://example.com/de/about/"/>
</url>Pages with only one locale version get no xhtml:link elements.
output
output?: {
mode?: 'single' | 'index';
maxUrls?: number;
filename?: string;
}| Option | Default | Description |
|---|---|---|
| mode | auto | 'single' forces one file; 'index' forces index mode; auto-selects based on URL count |
| maxUrls | 50000 | URLs per chunk before switching to index mode |
| filename | 'sitemap.xml' | Output filename in single mode |
When the URL count exceeds maxUrls, the plugin automatically generates sitemap-index.xml + numbered sitemap-N.xml chunks.
audit
audit?: {
warnOnEmpty?: boolean;
errorOnDuplicates?: boolean;
}| Option | Default | Description |
|---|---|---|
| warnOnEmpty | true | Warn when sitemap has zero entries |
| errorOnDuplicates | false | Treat duplicate URLs as errors instead of warnings |
debug
debug?: booleanEnables detailed logging during the build (collected page count, source batch sizes, deduplication results).
Recipes
Blog with content collections (static routes)
If blog posts are generated as static Astro pages (src/pages/blog/[slug].astro), they are automatically discovered from the build — no sources needed. Use priority/changefreq rules to tune the values:
// astro.config.mjs
astroSitemap({
priority: [
{ pattern: '/blog/', priority: 0.7 },
],
changefreq: [
{ pattern: '/blog/', changefreq: 'monthly' },
],
})External / headless CMS sources
Use sources for URLs that are not generated as Astro pages — e.g. content from a headless CMS that you fetch via API. Do not use import('astro:content') inside sources, as that virtual module is not available outside the Vite build context.
astroSitemap({
sources: [
async () => {
// Fetch from an external API
const res = await fetch('https://cms.example.com/api/pages');
const pages = await res.json();
return pages.map((p) => ({
loc: `/blog/${p.slug}/`,
lastmod: p.updatedAt,
priority: 0.7,
changefreq: 'monthly',
}));
},
],
})Blog + taxonomy pages (categories, series)
If taxonomy pages (/blog/category/[slug].astro) are also static Astro routes, they appear automatically too. Assign different priorities per path pattern:
astroSitemap({
priority: [
{ pattern: '/blog/category/', priority: 0.6 },
{ pattern: '/blog/', priority: 0.7 },
],
changefreq: [
{ pattern: '/blog/category/', changefreq: 'weekly' },
{ pattern: '/blog/', changefreq: 'monthly' },
],
})Exclude pages and override priorities
astroSitemap({
exclude: ['/intern/', '/preview/', /\/admin\//],
filter: (url) => !url.includes('?'),
priority: [
{ pattern: '/produkte/', priority: 0.9 },
{ pattern: '/blog/', priority: 0.7 },
{ pattern: '/impressum/', priority: 0.3 },
],
changefreq: [
{ pattern: '/blog/', changefreq: 'weekly' },
{ pattern: '/impressum/', changefreq: 'yearly' },
],
})Override lastmod from a CMS
Use serialize to enrich or drop individual entries after all sources are merged.
astroSitemap({
serialize: async (entry) => {
// Drop anything not yet ready
if (entry.loc.includes('/draft/')) return undefined;
// Fetch accurate lastmod from your CMS
const lastmod = await cms.getLastmod(entry.loc);
return lastmod ? { ...entry, lastmod } : entry;
},
})ResolvedSitemapEntry fields: loc, lastmod, priority, changefreq, links (hreflang, set by i18n).
See the
examples/directory for complete, copy-paste-ready recipes.
Multi-language site (i18n / hreflang)
// astro.config.mjs
export default defineConfig({
site: 'https://example.com',
i18n: {
defaultLocale: 'en',
locales: ['en', 'de', 'fr'],
},
integrations: [
astroSitemap({
i18n: {
defaultLocale: 'en',
locales: { en: 'en', de: 'de-DE', fr: 'fr-FR' },
},
}),
],
});Given pages /en/about/, /de/about/, /fr/about/, each URL gets <xhtml:link rel="alternate"> entries for all language variants automatically.
RSS feed alongside sitemap
// src/pages/rss.xml.ts
import { createRssRoute } from '@casoon/astro-sitemap/rss';
import { getCollection } from 'astro:content';
export const GET = createRssRoute({
title: 'My Blog',
description: 'Latest posts',
language: 'en',
copyright: `Copyright ${new Date().getFullYear()} ACME`,
getItems: async (siteUrl) => {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts
.sort((a, b) => +new Date(b.data.date) - +new Date(a.data.date))
.map((p) => ({
title: p.data.title,
description: p.data.description,
pubDate: p.data.date,
link: `${siteUrl}/blog/${p.id}/`,
author: p.data.author,
categories: p.data.tags,
}));
},
});How it works
- Config — Captures
site,base,trailingSlash,build.formatfrom Astro config.build.format: 'file'and'preserve'automatically append.htmlto non-index page URLs - Routes —
astro:routes:resolvedcollects i18n fallback routes - Pages —
astro:build:doneprovides the list of all rendered pages directly from Astro (no filesystem scan) - Sources — Each source function is awaited and its entries collected
- Resolve — Each entry gets
lastmod,priority,changefreqfrom user rules or built-in defaults - Deduplicate — Duplicate
locvalues are collapsed (last entry wins) - Serialize — The
serializehook runs per entry if configured - i18n — hreflang links are attached when
i18nis configured - Audit — Warnings/errors are emitted for empty sitemaps, duplicates, invalid priorities
- Write — Output is written as
sitemap.xmlorsitemap-index.xml+ chunks
Optional: RSS Feed Helper
The package ships an optional createRssRoute helper that generates an RSS 2.0 feed. It has no external dependencies and integrates naturally with Astro's API routes.
// src/pages/rss.xml.ts
import { createRssRoute } from '@casoon/astro-sitemap/rss';
import { getCollection } from 'astro:content';
export const GET = createRssRoute({
title: 'My Blog',
description: 'Latest posts',
language: 'en',
getItems: async (siteUrl) => {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts.map(p => ({
title: p.data.title,
pubDate: p.data.date,
link: `${siteUrl}/blog/${p.id}/`,
}));
},
});createRssRoute options
| Option | Type | Description |
|---|---|---|
| title | string | Feed title |
| description | string | Feed description |
| getItems | (siteUrl: string) => RssItem[] \| Promise<RssItem[]> | Returns feed items. Receives resolved siteUrl (no trailing slash) |
| siteUrl | string? | Override base URL (auto-detected from context.site) |
| feedUrl | string? | URL of this feed file — used for <atom:link rel="self">. Defaults to {siteUrl}/rss.xml |
| language | string? | Language code, e.g. 'en' or 'de-DE' |
| copyright | string? | Copyright notice |
| managingEditor | string? | Managing editor name or e-mail |
| feedCustomData | string? | Raw XML injected inside <channel> |
RssItem fields
| Field | Type | Description |
|---|---|---|
| title | string | Item title |
| pubDate | Date \| string | Publication date |
| link | string | Full URL or root-relative path (auto-prefixed with siteUrl) |
| description | string? | Item summary |
| author | string? | Author name or e-mail |
| categories | string[]? | Category / tag strings |
| customData | string? | Raw XML injected inside <item> |
Differences to @astrojs/sitemap
| Feature | @casoon/astro-sitemap | @astrojs/sitemap |
|---|---|---|
| URL discovery | Astro pages array + routes hook | Astro pages array + routes hook |
| URL filtering | exclude (patterns) + filter (function) | filter (function) |
| Dynamic sources | sources[] (async functions) | customPages (string array) |
| Priority | Depth-based defaults + pattern rules | Global default only |
| Changefreq | Pattern rules + smart defaults | Global default only |
| Per-item transform | serialize hook | serialize hook |
| i18n / hreflang | i18n option | i18n option |
| Audit | Warnings for empty/duplicates/invalid | None |
| XML formatting | Indented, human-readable | Minified (via sitemap package) |
| Chunked sitemaps | Auto + manual | Auto + chunks (named groups) |
| XSL stylesheet | — | xslURL option |
License
MIT
