The short version

Run npx astro add sitemap and set site in astro.config.mjs: that covers most static Astro sites. Its i18n option only links translations that share the same path, so for Storyblok sites with translated slugs, build a sitemap-[language].xml endpoint that reads each story's alternates. Point robots.txt at your sitemap yourself; the integration does not create one.

A multilingual site needs more than translated pages. A sitemap tells search engines which URLs exist, and on a multilingual site it can also tell them which pages are translations of each other. For most Astro projects the official @astrojs/sitemap integration is enough. This guide shows when that is the case, how to fix a sitemap that stays empty, and how to build one sitemap per language with hreflang alternates when your content comes from Storyblok with translated slugs.

The original version of this tutorial is a few years old, but the custom pattern still holds: fetch all stories for each language from Storyblok, read each story’s alternates and render an XML endpoint per locale. A few details have changed since. The headless CMS Delivery API v2 wraps its responses in an envelope, Astro endpoints export a GET function instead of get(), and a Response object sets the headers. The code below covers all of that.

Before writing this update, I tested the integration behavior with real builds on Astro 7.2.8 and @astrojs/sitemap 3.7.3.

What @astrojs/sitemap does out of the box

@astrojs/sitemap is Astro’s official integration for XML sitemaps. It reads every page that Astro generates at build time, including dynamic routes from getStaticPaths(), and writes the sitemap files when the build finishes. You add it with one command:

bash
npx astro add sitemap

The integration needs your production URL, so set site in astro.config.mjs:

js
import { defineConfig } from 'astro/config'
import sitemap from '@astrojs/sitemap'

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

The build then writes sitemap-index.xml and sitemap-0.xml to your output folder. The index links to the numbered files, and a new numbered file starts after 45,000 URLs by default (entryLimit). The options you will use most are filter() to leave pages out, serialize() to change or drop single entries, customPages for URLs that Astro does not build, and i18n for language alternates. The official @astrojs/sitemap documentation lists the rest.

@astrojs/sitemap or a custom endpoint?

Use the integration when every page you want indexed is built by Astro and your language versions share the same path. The i18n option adds xhtml:link alternates per locale:

js
sitemap({
  i18n: {
    defaultLocale: 'en',
    locales: { en: 'en', nl: 'nl' },
  },
})

In my test build, / and /nl/ got alternates pointing at each other, and so did /about and /nl/about. But /about and /nl/over-ons got none at all, because the integration pairs pages by their path after the locale prefix. It has no way of knowing that two different slugs are the same page.

On a Storyblok site with folder-level translations, that is the normal situation: editors give the Dutch page its own Dutch slug. Storyblok knows which stories belong together through each story’s alternates, and Astro’s sitemap integration never sees that data.

You then have two options:

  • Keep the integration and add the links yourself in serialize(), from a translation map you maintain. That is how this website does it: one map of NL/EN pairs feeds both the hreflang tags in the HTML and the links of each sitemap entry.
  • Build one sitemap endpoint per language that reads the alternates straight from Storyblok. There is nothing to maintain by hand, and the sitemap follows your content after every deploy.

The rest of this guide builds the second option.

Step 01

Check the prerequisites and install the Storyblok client

Before you begin, make sure you have the following:

  • Node.js (the current LTS version)
  • An Astro project (version 4 or later works with this pattern)

This example assumes folder-level localization: English stories at the root, Dutch stories under nl/, and translations linked as alternate stories. Turning on field-level translations does not fill these alternates. Configure site in astro.config.mjs and make sure each full_slug matches its public route.

From within the Astro project folder, install the universal JavaScript SDK for Storyblok’s API:

bash
npm install storyblok-js-client

Step 02

Set up the environment variables

Create a .env file in your Astro project with the following variables:

bash
STORYBLOK_TOKEN="your-storyblok-api-token"
STORYBLOK_VERSION="published"

Replace your-storyblok-api-token with a token from your Storyblok space. The endpoints read these variables only at build time, so they never end up in the browser bundle.

Step 03

Create the helper functions

Two small helpers keep the Storyblok fetching logic manageable. Neither depends on Astro, so you can reuse them in any project. I keep them in src/library.

First, getLocale.js derives the locale code from a Storyblok full slug. Adapt the locales to your own setup:

js
export function getLocale(slug = '') {
  if (slug.startsWith('nl/')) return 'nl'
  return 'en'
}

Next, add the shared Storyblok client in sb.js:

js
import StoryblokClient from 'storyblok-js-client'

export const sb = new StoryblokClient({
  accessToken: import.meta.env.STORYBLOK_TOKEN,
  region: 'eu',
})

export const defaultConfig = {
  version:
    import.meta.env.STORYBLOK_VERSION === 'draft' || import.meta.env.DEV
      ? 'draft'
      : 'published',
}

One thing worth knowing about the v2 Delivery API: the raw response is an envelope containing stories, cv (the cache version of your space), rels and links. The SDK’s getAll flattens that into a plain array of stories for you. If you ever fetch manually with get, pass cv along as a query parameter. It is the cache-buster that makes Storyblok’s CDN serve fresh content right after a publish.

Step 04

Create the sitemap endpoint per language

Now create the endpoint in your src/pages folder, for example sitemap-[language].xml.js. The language parameter generates one sitemap file per language at build time:

js
import { sb, defaultConfig } from '../library/sb'
import { getLocale } from '../library/getLocale'

export function getStaticPaths() {
  return ['en', 'nl'].map((language) => ({ params: { language } }))
}

export async function GET({ params, site }) {
  const stories = await sb.getAll('cdn/stories', {
    // Fetch all published languages so alternate targets can be verified.
    excluding_slugs: 'settings/*,templates/*,nl/settings/*,nl/templates/*',
    ...defaultConfig,
    version: 'published',
  })

  if (!site) throw new Error('Configure site in astro.config.mjs')
  const publishedSlugs = new Set(stories.map((story) => story.full_slug))
  const localizedStories = stories.filter(
    (story) => getLocale(story.full_slug) === params.language
  )
  const xmlEscape = (value) => String(value)
    .replaceAll('&', '&')
    .replaceAll('"', '"')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')

  const entries = localizedStories.map((story) => {
    const cleanSlug = (slug) => {
      const normalized = slug.replace(/\/$/, '')
      if (normalized === 'home') return ''
      if (normalized.endsWith('/home')) return normalized.slice(0, -5)
      return normalized
    }
    const self = {
      hreflang: getLocale(story.full_slug),
      href: `${site}${cleanSlug(story.full_slug)}`,
    }

    const alternates = (story.alternates ?? [])
      .filter((alternate) => alternate.published && publishedSlugs.has(alternate.full_slug))
      .map((alternate) => ({
      hreflang: getLocale(alternate.full_slug),
      href: `${site}${cleanSlug(alternate.full_slug)}`,
    }))

    const translatedPages = [...alternates, self]
    const defaultPage = translatedPages.find((link) => link.hreflang === 'en') ?? self
    const links = [...translatedPages, { hreflang: 'x-default', href: defaultPage.href }]
      .map((link) => `<xhtml:link rel="alternate" hreflang="${link.hreflang}" href="${xmlEscape(link.href)}"/>`)
      .join('')

    return `<url><loc>${xmlEscape(self.href)}</loc>${links}</url>`
  })

  const sitemap = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${entries.join('')}</urlset>`

  return new Response(sitemap, {
    headers: { 'Content-Type': 'application/xml' },
  })
}

Four details do the real work here. The locale filter keeps stories out of the wrong sitemap. The xhtml:link entries connect each URL to its translations, including a self-reference and an x-default fallback to the English version. The cleanSlug helper maps both home and localized */home stories to their locale roots and strips trailing slashes, so loc values match your canonical URLs. Because this is a static endpoint, the sitemap regenerates on every deploy and stays in sync with your content.

The xmlEscape helper keeps ampersands and other reserved characters from making the XML invalid. Filter out non-page stories and noindex pages according to your content model. Keep one alternate per language, and verify reciprocal links for every translation group. This folder-based example does not handle field-level translations or custom story paths automatically.

If you keep @astrojs/sitemap in the same project, the integration also lists the Storyblok pages that Astro builds, without the language links. Remove it, or leave those pages out with filter(), so every URL appears in only one sitemap.

Astro sitemap not generated? Check these first

When the sitemap is missing or incomplete, the build log usually tells you why. These are the causes I could reproduce on Astro 7.2.8 with @astrojs/sitemap 3.7.3:

  • No site in the config. The build finishes without errors, but logs The Sitemap integration requires the site astro.config option. Skipping. and writes no sitemap at all. Add site, including https://.
  • On-demand rendering. With output: 'server' and an adapter, dynamic routes such as src/pages/blog/[slug].astro are left out when they are not prerendered, because there is no getStaticPaths() list to read. Prerender those routes, or generate the sitemap in your own endpoint from the same data source. In my test the files also moved to dist/client, so check the right folder.
  • An async customPages. The option only takes an array of URLs. A function that fetches CMS entries makes the integration warn customPages Invalid input: expected array, received function, and it then writes no sitemap at all. Fetch that data in your own endpoint instead.
  • Trailing slashes that do not match your canonical URLs. By default the integration writes URLs with a trailing slash (/about/). With trailingSlash: 'never' it writes /about. Set the option to match the URLs your pages declare as canonical.
  • A base path. With base: '/docs', both the page URLs and the URL of sitemap-0.xml inside the index include /docs. Submit the index from that path, not from the domain root.

If you build your own endpoint, check that the file name ends in .xml.js or .xml.ts and that GET returns a Response with an XML content type. Then open the built file and compare the number of <url> entries with the number of pages you expect.

Add your Astro sitemap to robots.txt

The integration does not create a robots.txt file. A request to add that was closed as not planned. The simplest option is a static file in public/robots.txt:

User-agent: *
Allow: /

Sitemap: https://example.com/sitemap-index.xml

If the domain differs per environment, generate the file from the site value with an endpoint at src/pages/robots.txt.ts, as the Astro documentation shows:

ts
import type { APIRoute } from 'astro'

export const GET: APIRoute = ({ site }) =>
  new Response(
    `User-agent: *\nAllow: /\n\nSitemap: ${new URL('sitemap-index.xml', site).href}\n`
  )

With the custom endpoints from this guide, list each language file on its own Sitemap: line (sitemap-en.xml, sitemap-nl.xml). You can also link the sitemap from your layout’s <head> with <link rel="sitemap" href="/sitemap-index.xml" />.

Conclusion

Start with @astrojs/sitemap. For most static Astro sites it takes two lines of config. Once translated slugs from Storyblok enter the picture, two helper files and one endpoint give you a sitemap per language, correct hreflang alternates and a build that keeps both in sync with your content. I run into that choice in almost every headless website project. Submit each sitemap in Google Search Console so you can monitor which URLs Google discovers and indexes.

The same fetch pattern powers an RSS feed from Storyblok stories, which is worth adding while you are in there. If you would rather have help connecting these endpoints to your project, you can hire an Astro developer to set it up and verify it against your own content model.