The short version
Create a sitemap-[language].xml.js endpoint per locale, fetch stories with the Storyblok v2 Delivery API (the response is an envelope with stories, cv, rels and links), derive each hreflang from the story's alternates and let the static build keep everything up to date.
A multilingual site needs more than translated pages. Localized sitemaps are one reliable way to show search engines which pages are translations of each other. This guide adds them to an Astro project that pulls content from Storyblok: one sitemap per language, generated at build time with hreflang alternates.
The original version of this tutorial is a few years old, but the 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 underlying details have changed: 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 provides a clean way to set headers.
The code below is updated for all of that.
Prerequisites
Before you begin, make sure you have the following:
- Node.js (the current LTS version)
- An Astro project (Astro 7 is current; 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. Merely enabling field-level languages does not populate these alternates. Configure site in astro.config.mjs and make sure full_slug matches each public route.
Install dependencies
From within the Astro project folder, install the universal JavaScript SDK for Storyblok’s API:
npm install storyblok-js-clientSetting up environment variables
Create a .env file in your Astro project with the following variables:
STORYBLOK_TOKEN="your-storyblok-api-token"
STORYBLOK_VERSION="published"Replace your-storyblok-api-token with a token from your Storyblok space. Because these variables are only read at build time by the endpoints, they never end up in the browser bundle.
Creating helper functions
Two small helpers keep the Storyblok fetching logic manageable. Both are framework-agnostic, 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:
export function getLocale(slug = '') {
if (slug.startsWith('nl/')) return 'nl'
return 'en'
}Next, add the shared Storyblok client in sb.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.
Creating the sitemap endpoint
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:
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('<', '<')
.replaceAll('>', '>')
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.
Conclusion
With two helper files and one endpoint, you get a sitemap per language, correct hreflang alternates and a build process that keeps both in sync with your Storyblok content. 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 — 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.

