From content strategy to code, JoyConf brings the Storyblok community together - Register Now!

Build a Regionally Localized Website with Storyblok and Astro

Storyblok is the first headless CMS that works for developers & marketers alike.

In this second part of this tutorial series, we’ll pick up right where we left off in part 1, Build a Multilingual Website with Storyblok and Astro, where we set up field-level translation to offer our US-based startup’s homepage in English and Spanish.

Now, the company is preparing to launch in Europe — and they want region-specific content that’s more than just a language change. This is where Storyblok’s folder-level translation comes in. With it, we can build a structure that delivers localized content for each region, while still supporting multiple languages per region.

hint:

Before you start, make sure your astro, @storyblok/astro, and related dependencies are up-to-date and match the versions used in part 1.

hint:

Eager to see the complete implementation already? Take a look at the tutorial repository on GitHub.

Routing logic

Let’s first of all consider the desired routing logic. Up until now, we only had to consider the language codes being prepended to the path. With the introduction of regional localization, we’ll extend this pattern to include a region code before the language code, resulting in the following pattern:
/{region}/{lang}/{page}

However, to keep URLs short, we’ll make the region optional:

  • US content: no region code in the URL (default region)
  • EU content: eu region code appears in the URL

This results in the following pattern:
[/{region}]/{lang}/{page}

learn:

There’s no single “right” routing approach — you could use different domains, subdomains, or query params. Storyblok’s flexibility allows for any of these.

This pattern means we can serve regionally localized pages in multiple languages, all from the same Storyblok space.

Initial setup in Storyblok

With the routing decided, let’s set up our Storyblok content folders at the root level:

  • US
  • EU

Next, let's move all existing content to the US folder.

The Dimensions app simplifies cloning stories between regions, syncing updates, and automatically setting alternate versions of stories. Once installed, in the US folder, open a story for which you would like to create an equivalent in the EU market in the Visual Editor. Using Dimensions, located inside the languages submenu next to the History, create a clone. Learn more about the benefits and usage of Dimensions in the app description.

Without Dimensions, you can still copy the content manually. To set story alternates manually, enable it in Settings > Internationalization. Once enabled, you can find them in the Config section of the Visual Editor.

hint:

Don’t forget to check and update your links when cloning or mergies stories between the top-level folders.

Routing in Astro

In Astro, there are different ways to implement our desired routing logic. Creating a new src/pages/eu folder is the most straightforward solution. It works with Astro’s file-based routing automatically, makes it easier to manage static builds, and allows us to conveniently leverage Astro layouts for region-specific designs later on. Hence, we’ll have two dynamic routes in place:

  • src/pages/[...slug].astro: Receives everything from the US folder in Storyblok.
  • src/pages/eu/[...slug].astro: Receives everything from the EU folder in Storyblok.
src/pages/[…slug].astro
---
import { useStoryblokApi } from '@storyblok/astro';
import StoryblokComponent from '@storyblok/astro/StoryblokComponent.astro';
import Layout from '../layouts/Layout.astro';
import { getLanguageCodes } from '../utils/i18n';
import { getStoryblokPaths } from '../utils/ssr';

export async function getStaticPaths() {
	return await getStoryblokPaths('us');
}

const slug = Astro.params.slug?.split('/') ?? [];

const languages = await getLanguageCodes();
const language = languages.includes(slug[0]) ? slug[0] : undefined;

if (language) {
	slug.shift();
}

const storyblokApi = useStoryblokApi();
const { data } = await storyblokApi.get(
	`cdn/stories/us/${slug && slug.length > 0 ? slug.join('/') : 'home'}`,
	{
		version: 'draft',
		resolve_links: 'story',
		language,
	},
);

const story = data.story;
---

<Layout currentLanguage={language} currentRegion="us" story={story}>
	<StoryblokComponent currentLanguage={language} blok={story.content} />
</Layout>
src/pages/eu/[…slug].astro
---
import { useStoryblokApi } from '@storyblok/astro';
import StoryblokComponent from '@storyblok/astro/StoryblokComponent.astro';
import Layout from '../../layouts/Layout.astro';
import { getLanguageCodes } from '../../utils/i18n';
import { getStoryblokPaths } from '../../utils/ssr';

export async function getStaticPaths() {
	return await getStoryblokPaths('eu');
}

const slug = Astro.params.slug?.split('/') ?? [];

const languages = await getLanguageCodes();
const language = languages.includes(slug[0]) ? slug[0] : undefined;

if (language) {
	slug.shift();
}

const storyblokApi = useStoryblokApi();
const { data } = await storyblokApi.get(
	`cdn/stories/eu/${slug && slug.length > 0 ? slug.join('/') : 'home'}`,
	{
		version: 'draft',
		resolve_links: 'story',
		language,
	},
);

const story = data.story;
---

<Layout currentLanguage={language} currentRegion="eu" story={story}>
	<StoryblokComponent currentLanguage={language} blok={story.content} />
</Layout>

The route src/pages/es/index.astro from part 1 can be removed.

Notice that the logic in Astro's getStaticPaths for static site generation has been outsourced to a new getStoryblokPaths function, which we’ll consider in more detail towards the end of the tutorial.

Storyblok path configuration

For the routing logic to work correctly in Storyblok's Visual Editor, let's install the Advanced Paths app. By default, Storyblok’s Visual Editor would include the us region prefix in the requested URL, which we want to omit. Change the real path for folders by going to Content, selecting a folder, and opening its Settings via the toolbar.

The following configuration is required:

  • us: {{ it.lang ? it.lang + '/' : '' }}
  • eu: {{it.folder.slug}}{{ it.lang ? '/' + it.lang : '' }}
  • us/home: /
  • eu/home: /
  • us/lp: {{it.folder.slug}}/
  • eu/lp: /{{it.folder.slug}}/

Learn more about the possibilities of the Advanced Paths app in the app description.

learn:

If you don't have access to the Advanced Paths app, you can implement the required logic directly in your front-end code.


For example, you could use Astro's redirects (see the documentation) to forward from the path requested by the Visual Editor to the desired target path.


Alternatively, you could create a mapping in your implementation, rendering the actual requested path in the preview environment, but modifying the URLs used in production.

Extend the language switcher

We now have multiple regions, so our language switcher needs a region selector, too.

First of all, let’s add getRegionCodes function to i18n.js. Let’s hardcode these for now.

src/utils/i18n.js
const getRegionCodes = () => {
	return ['us', 'eu'];
};

Next, let’s update the getCleanSlug function so it strips out both the language and region codes:

src/utils/i18n.js
const getCleanSlug = async (slug) => {
	const slugParts = slug.split('/');

	const languages = await getLanguageCodes();
	const regions = getRegionCodes();

	let languageCode = '';

	if (languages.includes(slugParts[0])) {
		languageCode = slugParts[0];
		slugParts.shift();
	}

	let regionCode = '';
	if (regions.includes(slugParts[0])) {
		regionCode = slugParts[0];
		slugParts.shift();
	}

	return { slug: slugParts.join('/'), languageCode, regionCode };
};

In Header.astro, which should now also receive a prop currentRegion, we can get all available region and language codes and build the different region links, omitting the us prefix.

For the language links, we also need to consider whether the current region prefix should be part of the URL or not.

src/components/Header.astro
---
import {
	getRegionCodes,
	getLanguageCodes,
	getCleanSlug,
	getTranslatedSlug,
} from '../utils/i18n';

const { currentLanguage, currentRegion, story } = Astro.props;

const regions = getRegionCodes();
const languages = getLanguageCodes();

const storyFullSlug = story.default_full_slug || story.full_slug;
let baseSlug = '';
const homeSlugs = regions.map((region) => `${region}/home`);
if (!homeSlugs.includes(storyFullSlug)) {
	const slugData = await getCleanSlug(storyFullSlug);
	baseSlug = slugData.slug;
}

const regionLinks = regions.map((region) => {
	const includeRegion = region !== 'us' ? `${region}/` : '';
	return {
		region,
		href: `/${includeRegion}${baseSlug}`,
		active: region === currentRegion,
	};
});

const includeCurrentRegion = currentRegion !== 'us' ? `${currentRegion}/` : '';

const additionalLanguageLinks = await Promise.all(
	languages.map(async (lang) => {
		const translatedSlug = await getTranslatedSlug(story, lang);
		const fullSlug = translatedSlug
			? `${includeCurrentRegion}${lang}/${translatedSlug === 'home' ? '' : translatedSlug}`
			: `${includeCurrentRegion}${lang}/${baseSlug}`;

		return {
			lang,
			href: `/${fullSlug}`,
			active: lang === currentLanguage,
		};
	}),
);

const languageLinks = [
	{
		lang: 'en',
		href: `/${includeCurrentRegion}${baseSlug}`,
		active: !currentLanguage || currentLanguage === 'en',
	},
	...additionalLanguageLinks,
];
---

<header>
	<div>
		<label id="region-switcher">Choose a region:</label>
		<details aria-labelledby="region-switcher">
			<summary>{currentRegion || 'us'}</summary>
			<ul>
				{
					regionLinks.map(({ region, href, active }) => (
						<li>
							<a href={href} class={active ? 'active' : ''}>
								{region}
							</a>
						</li>
					))
				}
			</ul>
		</details>
	</div>
	<div>
		<label id="language-switcher">Choose a language:</label>
		<details aria-labelledby="language-switcher">
			<summary>{currentLanguage || 'en'}</summary>
			<ul>
				{
					languageLinks.map(({ lang, href, active }) => (
						<li>
							<a href={href} class={active ? 'active' : ''}>
								{lang}
							</a>
						</li>
					))
				}
			</ul>
		</details>
	</div>
</header>

In Layout.astro, make sure to receive the currentRegion prop from the dynamic route and pass it to the header component:

src/layouts/Layout.astro
---
import Header from '../components/Header.astro';
import AlternateLinks from '../components/AlternateLinks.astro';
const currentYear = new Date().getFullYear();
const { currentLanguage, currentRegion, story } = Astro.props;
---

<!doctype html>
<html lang={currentLanguage || 'en'}>
	<head>
		<meta charset="UTF-8" />
		<meta name="viewport" content="width=device-width" />
		<title>Storyblok & Astro</title>
		<link rel="stylesheet" href="/styles/global.css" />
		<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
		<link rel="icon" type="image/x-icon" href="/favicon.ico" />
		<AlternateLinks currentLanguage={currentLanguage} story={story} />
		<meta name="generator" content={Astro.generator} />
	</head>
	<body>
		<Header
			currentLanguage={currentLanguage}
			currentRegion={currentRegion}
			story={story}
		/>
		<slot />
		<footer>All rights reserved © {currentYear}</footer>
	</body>
</html>

Internal links need to respect both the current language and region. Let’s update the getInternalLink function in i18n.js as follows:

src/utils/i18n.js
const getInternalLink = async (linkSlug, currentLanguage) => {
	if (!linkSlug) return '';

	const { slug, languageCode, regionCode } = await getCleanSlug(linkSlug);

	const includeRegion =
		regionCode && regionCode !== 'us' ? `${regionCode}/` : '';

	let includeLanguage = '';
	if (languageCode || currentLanguage) {
		includeLanguage = (languageCode || currentLanguage) + '/';
	}

	if (slug === 'home') {
		return `/${includeRegion}${includeLanguage}`;
	}

	return `/${includeRegion}${includeLanguage}${slug}`;
};

No changes to Button.astro are required.

Add further languages

For the EU content, we would probably want to offer additional languages beyond English and Spanish. Let's go to Settings > Internationalization to configure these. For example, you may want to add French (fr) and German (de).

Now, we also need to consider that French and German content should not be selectable for the US region on the front end. In other words, the selected region should also provide information on the available languages on the front end. This can easily be hardcoded. Let’s update i18n.js again.

src/utils/i18n.js
const languageCodesPerRegion = {
	us: ['es'],
	eu: ['es', 'fr', 'de'],
};

With these changes in place, let's also introduce a new getAvailableLanguagesForRegion helper function:

src/utils/i18n.js
const getAvailableLanguagesForRegion = (region) => {
	return languageCodesPerRegion[region] || [];
};

This can be conveniently used in src/components/Header.astro to render the available languages for the currently active region:

const languages = getAvailableLanguagesForRegion(currentRegion);

Manage region codes via Storyblok

Optionally, we could utilize Storyblok’s datasources to manage regions and their language associations. Let’s create a new datasource called “Regions and Languages” with the following name/value pairs:

  • us: es
  • eu: fr,es,de
hint:

The language codes specified as value should match the codes configured in Settings > Internationalization.

Let’s make the changes to the code in i18n.js:

src/utils/i18n.js
const getStoryblokRegionLanguageCodes = async () => {
	const storyblokApi = useStoryblokApi();
	const { data } = await storyblokApi.get('cdn/datasource_entries', {
		datasource: 'regions-and-languages',
	});
	return data.datasource_entries;
};

const getRegionCodes = async () => {
	const codes = await getStoryblokRegionLanguageCodes();
	return codes.map((code) => code.name);
};

const getAvailableLanguagesForRegion = async (region) => {
	const codes = await getStoryblokRegionLanguageCodes();
	const languageCodesPerRegion = Object.fromEntries(
		codes.map((code) => [code.name, code.value.split(',')]),
	);
	return languageCodesPerRegion[region] || [];
};
hint:

After implementing these changes, don’t forget to use await wherever you use getRegionCodes and getAvailableLanguagesForRegion.

Build a static site

Let’s take a look at the getStoryblokPaths used in the dynamic Astro routes. For this, we can again leverage the links endpoint of the Content Delivery API, and use the starts_with parameter to only fetch content located in us or eu respectively. We just need to strip out the region prefix for each entry, since Astro’s file-based system already handles our routing logic. From there, it’s just a matter of adding each cleaned slug (plus any available translated versions) to the staticPaths array.

src/utils/ssr.js
import { useStoryblokApi } from '@storyblok/astro';
import { getCleanSlug, getLanguageCodes } from './i18n';
export async function getStoryblokPaths(region = 'us') {
	const languages = await getLanguageCodes();
	const storyblokApi = useStoryblokApi();

	const links = await storyblokApi.getAll('cdn/links', {
		version: 'draft',
		starts_with: region,
	});

	const staticPaths = [];

	for await (const link of links) {
		if (link.is_folder) continue;
		const cleanSlug = await getCleanSlug(link.slug);

		staticPaths.push({
			params: {
				slug: cleanSlug.slug === 'home' ? undefined : cleanSlug.slug,
			},
		});

		if (link.alternates && link.alternates.length > 0) {
			for await (const alternate of link.alternates) {
				if (languages.includes(alternate.lang)) {
					const cleanSlug = await getCleanSlug(alternate.translated_slug);
					staticPaths.push({
						params: {
							slug: `${alternate.lang}/${
								cleanSlug.slug === 'home' ? '' : cleanSlug.slug
							}`,
						},
					});
				}
			}
		}
	}

	console.log(
		`Generated ${staticPaths.length} static paths for region: ${region}`,
	);
	console.log(staticPaths);

	return staticPaths;
}
learn:

Note that the alternates array returned by the links endpoint is not to be confused by the story alternates returned by the stories endpoint.

Change the output to static in astro.config.mjs and give it a try locally by running npm run build && npm run preview!

Wrap up

Congratulations! You've successfully upgraded your Storyblok + Astro website with regional localization and multilingual support!

In the next part of this series, we’ll follow the company as it grows to the point where dedicated teams manage content in their own spaces — an ideal scenario for introducing space-level translation. Stay tuned!

In the meantime, here are some ideas to take it further from here:

  • Implement a graceful fallback strategy. If a region-specific story doesn’t exist, seamlessly fall back to another region’s content instead of serving an error page.
  • Implement region-aware <link rel="alternate"> tags based on the alternates and translated_slugs arrays included in the story response.
  • Enable independent publishing for each language version in Settings > Internationalization, giving content teams the flexibility to publish updates for one language without having to push changes for all others.

Resources

Author

Manuel Schröder

Manuel Schröder

A former International Relations graduate, Manuel ultimately pursued a career in web development, working as a frontend engineer. His favorite technologies, other than Storyblok, include Vue, Astro, and Tailwind. These days, Manuel coordinates and oversees Storyblok's technical documentation, combining his technical expertise with his passion for writing and communication.