Type something to search...
How to generate a sitemap in Astro?

How to generate a sitemap in Astro?

To generate a sitemap in Astro, install the official @astrojs/sitemap integration with npx astro add sitemap and set the site option in astro.config.mjs to your production URL. On every build, Astro writes a sitemap-index.xml file and one or more numbered files such as sitemap-0.xml to your output folder, listing every prerendered page, including the pages that dynamic routes create with getStaticPaths(). You then point search engines at sitemap-index.xml from robots.txt and, optionally, a <link rel="sitemap"> tag. Options such as filter, serialize and chunks let you exclude pages, add lastmod dates and split the sitemap into sections.

A sitemap doesn't make pages rank, but it helps search engines discover them and notice when they change, which matters most for large sites, new sites and sites with pages that few other pages link to. In this article you'll learn how to install and configure the integration, what it generates, how to exclude pages, how to add accurate last-modified dates from your content, how to split and localise sitemaps, and how to handle on-demand rendered routes that the integration can't see.

Installing the Integration

The quickest way is the astro add command, which installs the package and updates your config:

npx astro add sitemap

At the time of writing, this installs @astrojs/sitemap 3.7.4. If you'd rather do it by hand, install the package and add it to integrations:

npm install @astrojs/sitemap
// astro.config.mjs
import { defineConfig } from "astro/config";
import sitemap from "@astrojs/sitemap";

export default defineConfig({
  site: "https://example.com",
  integrations: [sitemap()],
});

The site option is required. Sitemaps must contain absolute URLs, and the integration builds them from this value, which must start with http:// or https://.

What Gets Generated

Run a build and look in dist/:

npx astro build
[@astrojs/sitemap] `sitemap-index.xml` created at `dist`

In a small test project with a home page and two blog posts, the integration wrote two files. The index lists each sitemap file:

<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap>
    <loc>https://example.com/sitemap-0.xml</loc>
  </sitemap>
</sitemapindex>

And sitemap-0.xml lists the pages:

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" ...>
  <url><loc>https://example.com/</loc></url>
  <url><loc>https://example.com/blog/first-post/</loc></url>
  <url><loc>https://example.com/blog/second-post/</loc></url>
</urlset>

I've formatted these for readability; the real files are written on a single line. A few things to notice:

  • Only pages are included: my robots.txt.ts endpoint and other non-HTML endpoints didn't appear.
  • Dynamic routes are expanded: the blog posts came from a [id].astro route with getStaticPaths().
  • URLs follow your trailing slash setup: with the default directory build format, they end in /. Make sure that matches your canonical URLs, as discussed in adding SEO meta tags.
  • Large sites are split automatically: each file holds up to 45,000 entries by default, configurable with entryLimit, and extra files such as sitemap-1.xml are added and linked from the index.

Telling Search Engines Where It Is

Search engines look for sitemaps in robots.txt. Add a Sitemap line with the full URL of the index file. You can write a static public/robots.txt, or generate it from the site value with an endpoint:

// src/pages/robots.txt.ts
import type { APIRoute } from "astro";

export const GET: APIRoute = ({ site }) => {
  const sitemapURL = new URL("sitemap-index.xml", site);
  return new Response(
    `User-agent: *\nAllow: /\n\nSitemap: ${sitemapURL.href}\n`,
    { headers: { "Content-Type": "text/plain; charset=utf-8" } },
  );
};

You can also add a link in your layout's <head>:

<link rel="sitemap" href="/sitemap-index.xml" />

Finally, submit the index URL in Google Search Console and Bing Webmaster Tools. They'll report how many URLs they found and any errors.

Excluding Pages with filter

By default, every page is included. That often means admin screens, search results, thank-you pages and other pages you don't want in search. The filter option receives each page's full URL and returns true to keep it:

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

const site = "https://example.com";

export default defineConfig({
  site,
  integrations: [
    sitemap({
      filter: (page) =>
        !page.startsWith(`${site}/admin/`) && page !== `${site}/search/`,
    }),
  ],
});

Remember that the URL includes the domain and, with default settings, a trailing slash. A filter comparing against /search without the slash would silently fail to match.

Keep the sitemap and your noindex tags in agreement. A page that tells search engines not to index it shouldn't be listed in the sitemap, and vice versa.

Adding lastmod Dates

The <lastmod> element tells search engines when a page last changed. Google says it uses lastmod when it's consistently accurate, and ignores changefreq and priority, so lastmod is the one worth setting.

The integration can't read your pages' source, so it doesn't know when content changed. The site-wide lastmod option sets the same date on every URL, which isn't accurate. Instead, use serialize(), which is called for each entry before it's written, together with dates from your content.

The config file runs before content collections are available, so getCollection() can't be used there. A simple approach is to read the dates from the Markdown frontmatter with Node's file system API:

// astro.config.mjs
import { defineConfig } from "astro/config";
import sitemap from "@astrojs/sitemap";
import { readdirSync, readFileSync } from "node:fs";

const site = "https://example.com";

// Map each post URL to its last modified date from frontmatter
const lastmods = new Map();

for (const file of readdirSync("./src/content/blog")) {
  if (!file.endsWith(".md")) continue;
  const source = readFileSync(`./src/content/blog/${file}`, "utf-8");
  const date =
    source.match(/^updatedDate:\s*(.+)$/m)?.[1] ??
    source.match(/^pubDate:\s*(.+)$/m)?.[1];
  if (date) {
    const id = file.replace(/\.md$/, "");
    lastmods.set(`${site}/blog/${id}/`, new Date(date).toISOString());
  }
}

export default defineConfig({
  site,
  integrations: [
    sitemap({
      filter: (page) =>
        !page.startsWith(`${site}/admin/`) && page !== `${site}/search/`,
      serialize(item) {
        const lastmod = lastmods.get(item.url);
        if (lastmod) item.lastmod = lastmod;
        return item;
      },
    }),
  ],
});

This assumes post IDs match their file names, which is how the glob() loader generates them for flat folders, and frontmatter fields named pubDate and an optional updatedDate. Adjust the regular expressions and URL pattern to match your own schema and routes. If you use nested folders, read them recursively.

With one post published on 1 October and another updated on 20 October, the output became:

<url><loc>https://example.com/</loc></url>
<url>
  <loc>https://example.com/blog/first-post/</loc>
  <lastmod>2026-10-01T00:00:00.000Z</lastmod>
</url>
<url>
  <loc>https://example.com/blog/second-post/</loc>
  <lastmod>2026-10-20T00:00:00.000Z</lastmod>
</url>

The integration also added the most recent date to the index file's entry for sitemap-0.xml, which I hadn't expected:

<sitemap>
  <loc>https://example.com/sitemap-0.xml</loc>
  <lastmod>2026-10-20T00:00:00.000Z</lastmod>
</sitemap>

serialize() can also return undefined to drop an entry, so it can replace filter when the decision needs more than the URL.

Use Real Change Dates

Avoid setting lastmod to the build time for every page. Every deploy would then claim that every page changed, and search engines learn to ignore the field. Only update updatedDate when the content meaningfully changes.

Splitting the Sitemap into Sections

The chunks option, added in version 3.7.0, splits entries into named files. Each key becomes a file name, and its function returns the item to include it or undefined to skip it:

// astro.config.mjs (excerpt)
sitemap({
  chunks: {
    blog: (item) => (item.url.includes("/blog/") ? item : undefined),
  },
});

This produced three files in my test:

sitemap-blog-0.xml
sitemap-index.xml
sitemap-pages-0.xml

URLs that don't match any chunk go into sitemap-pages-0.xml. Separate files make Search Console reports easier to read, because you can see indexing coverage per section. A chunk function can also modify the item, for example to set a lastmod.

Localised Sitemaps

For multilingual sites, the i18n option adds hreflang alternates for each page. Its locales keys are the path prefixes and the values are language codes:

// astro.config.mjs (excerpt)
sitemap({
  i18n: {
    defaultLocale: "en",
    locales: {
      en: "en-US",
      es: "es-ES",
      fr: "fr-CA",
    },
  },
});

Each <url> then includes <xhtml:link rel="alternate" hreflang="..."> entries pointing at its translations. URLs without a locale prefix are treated as the default locale. The defaultLocale must be one of the locales keys. See i18n routing in Astro for setting up the routes themselves.

Other Useful Options

OptionPurpose
customPagesAdd URLs that exist on your domain but aren't built by Astro
customSitemapsAdd other sitemap files, such as a shop's sitemap, to the index
entryLimitChange the maximum URLs per file from the default 45,000
filenameBaseChange the sitemap prefix, for example to avoid clashing with existing files
xslURLLink an XSL stylesheet so the sitemap displays nicely in a browser
changefreq, priority, lastmodSite-wide values; Google ignores the first two

On-Demand Rendered Routes

The integration builds its list from the pages Astro generates at build time. It can't list the pages of dynamic routes rendered on demand, because those URLs don't exist until someone requests them. If part of your site uses on-demand rendering, such as product pages fetched from a database, you have two options:

  • customPages: compute the URLs in your config and pass them in, if you can fetch them at build time.
  • A custom sitemap endpoint: generate a sitemap file from your data and list it in customSitemaps.

Here's a custom sitemap endpoint for blog posts from a content collection. The same structure works with data from a database or API:

// src/pages/sitemap-posts.xml.ts
import type { APIRoute } from "astro";
import { getCollection } from "astro:content";

export const GET: APIRoute = async ({ site }) => {
  const posts = await getCollection("blog", ({ data }) => !data.draft);

  const urls = posts
    .map((post) => {
      const loc = new URL(`/blog/${post.id}/`, site).href;
      const lastmod = (post.data.updatedDate ?? post.data.pubDate).toISOString();
      return `<url><loc>${loc}</loc><lastmod>${lastmod}</lastmod></url>`;
    })
    .join("");

  return new Response(
    `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`,
    { headers: { "Content-Type": "application/xml" } },
  );
};

The build wrote dist/sitemap-posts.xml:

<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://example.com/blog/first-post/</loc><lastmod>2026-10-01T00:00:00.000Z</lastmod></url><url><loc>https://example.com/blog/second-post/</loc><lastmod>2026-10-20T00:00:00.000Z</lastmod></url></urlset>

Then register it with customSitemaps: ["https://example.com/sitemap-posts.xml"] so it appears in the index. If the endpoint is rendered on demand, add export const prerender = false and consider caching it, as described in caching on-demand pages. If your URLs could contain characters such as &, escape them before inserting them into the XML.

Troubleshooting

  • No sitemap files: check that site is set and the integration is in the integrations array. The build log should show the sitemap-index.xml created line.
  • Wrong domain: the URLs come from site, so a staging value will leak into production builds. Set it from an environment variable if you build for several environments.
  • Missing pages: on-demand rendered dynamic routes are not included. Use one of the approaches above.
  • Filter not working: log the page argument to see the exact URL format, including the trailing slash.
  • Search Console shows "Couldn't fetch": open the sitemap URL in a browser on the deployed site to make sure it returns XML with a 200 status.

FAQ: Sitemaps in Astro

Not by default. Add the official @astrojs/sitemap integration with npx astro add sitemap and set the site option, and a sitemap is generated on every build.

The most common reason is a missing site option in astro.config.mjs. The integration needs your full production URL, starting with http:// or https://, to build absolute URLs.

Submit sitemap-index.xml. It links to every numbered or chunked sitemap file, so search engines find all of them from that one URL.

Use the filter option, which receives each page's full URL and returns true to include it. Compare against the complete URL, including the domain and any trailing slash.

Static on-demand pages can be included, but the integration cannot list the URLs of dynamic routes rendered on demand. Add them with customPages or generate a separate sitemap endpoint and list it in customSitemaps.

You can, but Google ignores both. An accurate lastmod for each page, set through serialize or a custom sitemap, is far more useful.


Conclusion

Generating a sitemap in Astro takes one command and one config value: npx astro add sitemap and site. From then on, every build produces a sitemap-index.xml and numbered sitemap files covering all your prerendered pages, including those from dynamic routes. Reference the index from robots.txt, submit it in Search Console, and use filter to keep admin and search pages out.

For a more useful sitemap, add real lastmod dates through serialize(), split large sites into sections with chunks, and add hreflang alternates with i18n if you publish in several languages. For routes rendered on demand, generate your own sitemap endpoint and list it with customSitemaps. Kept accurate, the sitemap becomes a reliable signal that helps search engines find new and updated content quickly.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper