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

Build a Multilingual Website with Storyblok and Astro

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

In this tutorial, we’ll start exploring how to implement an effective internationalization strategy using Storyblok as a headless CMS, combined with Astro, a modern frontend framework. For this purpose, we'll assume the perspective of a small US-American startup, currently offering its products exclusively in the domestic market. Looking for ways to increase sales and revenue, the company aims to additionally offer its online content in Spanish. Structurally and contentually, the Spanish version shall not differ–in other words, for now, localization is not being considered. This scenario describes the perfect use case for Storyblok's field-level translation, which we'll implement in Astro, considering not only how to fetch specific language versions, but also how to switch between them in the frontend, how to handle links, how to deploy the project as a static site successfully, and more. Let's get started!

hint:

This tutorial has been tested with the following package versions:

  • astro@5.10.1
  • storyblok-astro@7.1.1
  • Node.js v22.13.0

Setup

First of all, let's create a new blank Storyblok space and connect it with the Astro starter blueprint. For detailed instructions, check out our Astro guide.

Next, set up an additional language as shown in the internationalization concept. For this tutorial, let's add Spanish with the language code es. Let's also rename the default language to English.

Now, we can proceed by customizing the content model to fit the use case described in the introduction. Having a selection of context-relevant and translation-ready blocks at our disposal will facilitate our understanding of how to implement Storyblok's field-level internationalization.

In the block library, create the following nestable blocks:

These nestable blocks should be used in combination with the page content type included by default.

learn:

If you're unfamiliar with content modeling in Storyblok, take a look at the blocks and fields concepts. Learn how to enable translatable fields in the internationalization concept.

Now, let's dive into the code and add the Astro component counterparts for our newly created blocks. First, we need to register them. Let's also remove a few lines from the starter blueprint that we won't need.

hint:

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

astro.config.mjs
import { defineConfig } from 'astro/config';
import { storyblok } from '@storyblok/astro';
import { loadEnv } from 'vite';
import mkcert from 'vite-plugin-mkcert';

const env = loadEnv(import.meta.env.MODE, process.cwd(), '');
const { STORYBLOK_DELIVERY_API_TOKEN } = env;

export default defineConfig({
	integrations: [
		storyblok({
			accessToken: STORYBLOK_DELIVERY_API_TOKEN,
			apiOptions: {
				/** Set the correct region for your space. Learn more: https://www.storyblok.com/docs/packages/storyblok-js#example-region-parameter */
				region: 'eu',
			},
			components: {
				page: 'storyblok/Page',
				grid: 'storyblok/Grid',
				feature: 'storyblok/Feature',
				teaser: 'storyblok/Teaser',
				banner: 'storyblok/Banner',
				button: 'storyblok/Button',
				image_text: 'storyblok/ImageText',
			},
		}),
	],
	site: 'https://your-site.com', // Replace with your actual site URL
	output: 'server',
	vite: {
		plugins: [mkcert()],
	},
});

Hereafter, let's add them to the src/storyblok folder.

src/storyblok/Banner.astro
---
import StoryblokComponent from '@storyblok/astro/StoryblokComponent.astro';
import { storyblokEditable } from '@storyblok/astro';
const { blok } = Astro.props;
---

<section class="banner" {...storyblokEditable(blok)}>
	<h2>{blok.headline}</h2>
	{ blok.buttons && blok.buttons.length > 0 && (
	<div class="button-container">
		{blok.buttons?.map((button) => { return (
		<StoryblokComponent blok="{button}" />
		); })}
	</div>
	) }
</section>
src/storyblok/ImageText.astro
---
import StoryblokComponent from '@storyblok/astro/StoryblokComponent.astro';
import { storyblokEditable, renderRichText } from '@storyblok/astro';
const { blok } = Astro.props;
const renderedRichText = renderRichText(blok.text);
---

<section class="image-text" {...storyblokEditable(blok)}>
	{ blok.image && blok.image?.filename && (
	<div>
		<img src="{blok.image.filename}" alt="{blok.image.alt}" />
	</div>
	) }
	<div>
		<h3>{blok.headline}</h3>
		<Fragment set:html="{renderedRichText}" />
		{ blok.buttons && blok.buttons.length > 0 && (
		<div class="button-container">
			{blok.buttons?.map((button) => { return (
			<StoryblokComponent blok="{button}" />
			); })}
		</div>
		) }
	</div>
</section>
src/storyblok/Button.astro
---
import { storyblokEditable } from '@storyblok/astro';

const { blok } = Astro.props;

let url = '';
if (blok.link.linktype === 'story') {
	url = `/${blok.link?.story?.full_slug}`;
} else if (blok.link.linktype === 'url') {
	url = blok.link?.url;
}
---

<a href={url} class="button" {...storyblokEditable(blok)}>
	{blok.label}
</a>

In order to resolve the link field in the button component correctly, we need to make a minor modification to src/pages/[...slug].astro. Learn more about the resolve_links parameter in our API docs.

src/pages/[...slug].astro
---
import { useStoryblokApi } from '@storyblok/astro';
import StoryblokComponent from '@storyblok/astro/StoryblokComponent.astro';
import Layout from '../layouts/Layout.astro';

const { slug } = Astro.params;

const storyblokApi = useStoryblokApi();
const { data } = await storyblokApi.get(`cdn/stories/${slug || 'home'}`, {
	version: 'draft',
    resolve_links: 'story',
});

const story = data.story;
---

<Layout>
	<StoryblokComponent blok={story.content} />
</Layout>

Finally, let's add some styles. In src/layouts/Layout.astro, let's replace the default blueprint CSS file with our own local copy, extended with styles for our newly created components in the components layer.

src/layouts/Layout.astro
---
const currentYear = new Date().getFullYear();
---

<!doctype html>
<html lang="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" />
		<meta name="generator" content={Astro.generator} />
	</head>
	<body>
		<slot />
		<footer>All rights reserved © {currentYear}</footer>
	</body>
</html>
public/styles/global.css
@layer reset, defaults, components;

@layer reset {
	*,
	*::before,
	*::after {
		box-sizing: border-box;
	}

	* {
		margin: 0;
		padding: 0;
	}

	body {
		line-height: 1.5;
	}

	img,
	picture,
	video,
	canvas,
	svg {
		display: block;
		max-inline-size: 100%;
	}

	input,
	button,
	textarea,
	select {
		font: inherit;
		letter-spacing: inherit;
		word-spacing: inherit;
		color: currentColor;
	}

	:is(p, h1, h2, h3) {
		overflow-wrap: break-word;
	}

	:is(ol, ul) {
		padding-inline-start: 1.5em;

		& li {
			margin-block: 0.5em;
		}
	}
}

@layer defaults {
	:root {
		--light: #fcfcfc;
		--dark: #1f1f1f;
		--text: light-dark(var(--dark), var(--light));
		--surface: light-dark(var(--light), var(--dark));
		--accent: #ebe8ff;
		--accent-dark: #184db5;
	}

	html {
		block-size: 100%;
		color-scheme: light dark;
	}

	@media (prefers-reduced-motion: no-preference) {
		html {
			scroll-behavior: smooth;
		}
	}

	body {
		font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
		max-inline-size: 65rem;
		min-block-size: 100dvh;
		margin: 0 auto;
		padding: 1rem;
		color: var(--text);
		background-color: var(--surface);
		font-size: clamp(1rem, -0.5vw + 1.3rem, 1.2rem);
		text-rendering: optimizeSpeed;
		-webkit-font-smoothing: antialiased;
		-moz-osx-font-smoothing: grayscale;
		text-wrap: pretty;
	}

	main {
		display: grid;
		gap: 2rem;
		padding: max(2vh, 1rem) min(2vw, 2rem);
	}

	:is(h1, h2, h3) {
		margin-block: 1lh 0.5lh;
		line-height: 1.2;
		text-wrap: balance;
		letter-spacing: -0.05ch;
	}

	a {
		color: var(--accent-dark);
	}

	footer {
		border-block-start: 1px solid;
		text-align: center;
		padding: max(2vh, 1rem) min(2vw, 2rem);
		margin: max(2vh, 5rem) min(2vw, 3rem);
	}
}

@layer components {
	.teaser {
		color: var(--dark);
		background-color: var(--accent);
		border-radius: 24px;
		text-align: center;
		padding: 4rem 1rem;
	}

	.grid {
		display: grid;
		grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
		gap: 2rem;
	}

	.feature {
		padding: 2rem;
		background-color: hsl(from var(--light) h s calc(l - 3));
		border: 2px solid;
		border-radius: 24px;
		color: var(--dark);
		text-align: center;
		font-weight: 600;
	}

	.image-text {
		display: grid;
		grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
		gap: 2rem;
		align-items: center;

		img {
			width: 100%;
			height: auto;
			border-radius: 24px;
		}

		p {
			margin-block-start: 0.5lh;
			margin-block-end: 0.5lh;
			text-wrap: balance;
		}
	}

	.banner {
		display: flex;
		flex-direction: column;
		align-items: center;
		justify-content: center;
		padding: 2rem;
		background: linear-gradient(
			135deg,
			hsl(from var(--accent-dark) h s calc(l + 4)) 0%,
			hsl(from var(--accent-dark) h s calc(l - 4)) 100%
		);
		color: var(--light);
		border-radius: 24px;
		text-align: center;

		h2 {
			margin-block-end: 1lh;
			font-size: clamp(2rem, 1vw + 2rem, 4rem);
			font-weight: 600;
		}

		.button {
			background-color: var(--accent);
			color: var(--dark);
			&:hover {
				background-color: hsl(from var(--accent) h s calc(l - 6));
			}
		}
	}

	.button-container {
		display: flex;
		gap: 1rem;
		flex-wrap: wrap;
		margin-top: 2rem;
	}

	.button {
		display: inline-block;
		padding: 0.5rem 1rem;
		background-color: var(--accent-dark);
		color: var(--light);
		text-decoration: none;
		border-radius: 12px;
		font-weight: 600;
		transition: background-color 0.3s ease;

		&:hover {
			background-color: hsl(from var(--accent-dark) h s calc(l - 6));
		}
	}

	article {
		max-width: 65ch;
		margin: 0 auto;

		& p + p {
			margin-block-start: 0.5lh;
		}
	}
}

Add sample content in Storyblok

Next, let's create some sample content using these new blocks in Storyblok. We can use the existing home story and add some sample content using the banner and image_text nestable blocks we created. Similarly, using these blocks, let's create a new folder with the slug lp and add two or three sample landing pages.

Here's the intended content structure:

  • home story
  • lp folder
    • landing-page-1 story
    • landing-page-2 story

Opening, for example, the lp/landing-page-1 story in the Visual Editor allows you to use the Languages toggle menu, located next to the History button at the top right. Changing the language to Spanish lets you enable translations for the translatable fields of our banner, image_text, and button nestable blocks.

Make sure to provide sample translations for your fields to allow us to determine whether it is rendered correctly. Remember that field-level translation in Storyblok isn't limited to textual content. In the content model we created, you could, for example, also "translate" the image field of the image_text nestable block.

learn:

Storyblok's AI Translations feature, as well as the integration provided by our tech partner Lokalise make translating content a breeze.

At this point, you may have already noticed the Astro project running in the preview showing an error message. Let's fix that in the code, next!

Fetch the correct language versions

So, what's actually causing the error here? When taking a closer look at the preview area of the Visual Editor, you'll see that the language code is prepended to the slug when changing the language. For example, lp/landing-page-1 becomes es/lp/landing-page-1. Currently, src/pages/[...slug].astro is configured to assume that the slug matches an exact API endpoint for a single story. However, the API endpoint for the Spanish version is identical to that of the English (default) version – or any other language version, for that matter. Hence, the error is caused because the endpoint that is being called does not exist.

In order to retrieve a specific language version, the language parameter has to be used as shown in the internationalization concept. Therefore, we should change the code to remove the language code from the slug and provide it as the value of the language parameter used in the API request instead.

src/pages/[...slug].astro
---
import { useStoryblokApi } from '@storyblok/astro';
import StoryblokComponent from '@storyblok/astro/StoryblokComponent.astro';
import Layout from '../layouts/Layout.astro';

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

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

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

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

const story = data.story;
---

<Layout>
	<StoryblokComponent blok={story.content} />
</Layout>

If the first element of the slug array matches one of the language codes defined in the languages array, it is removed from the slug array and used for the parameter.

Since the array of available language codes is likely to be needed in multiple parts of our Astro project, let's extract it into a reusable helper function. Moreover, while hardcoding the language codes gets the job done, we can increase the robustness and scalability of our frontend by fetching this information directly from Storyblok (using the spaces endpoint).

src/utils/i18n.js
import { useStoryblokApi } from '@storyblok/astro';

const getLanguageCodes = async () => {
	const storyblokApi = useStoryblokApi();
	const { data } = await storyblokApi.get('cdn/spaces/me');

	return data.space.language_codes;
};

export { getLanguageCodes };

Now, we can import { getLanguageCodes } from '../utils/i18n' in src/pages/[...slug].astro instead of hardcoding the array.

With these changes in place, upon refreshing the page in the Visual Editor, you'll see that not only is the error gone, but, more excitingly, your translations are also already being rendered!

hint:

A brief note on conceptualizing the path structure: As we've seen, by default, Storyblok prepends the language code to the path in the Visual Editor. While we aim to adhere to that approach in our frontend for the purpose of this tutorial, it is certainly not the only conceivable way. You're free to implement any logic to determine what language version should be loaded, be it via different domains, a custom path structure, a URL parameter, etc. The Advanced Paths app lets you configure the real path for folders and stories, matching the path requested by the Visual Editor to that of your production environment.

If you changed the real path of your home story to /, you'll need to handle the logic responsible for fetching the correct language version differently, as the language code is no longer part of the slug. Let's address that by creating a src/pages/es/index.astro file, fetching the home story in Spanish when the /es route is being visited.

src/pages/es/index.astro
---
import { useStoryblokApi } from '@storyblok/astro';
import StoryblokComponent from '@storyblok/astro/StoryblokComponent.astro';
import Layout from '../../layouts/Layout.astro';

const storyblokApi = useStoryblokApi();
const { data } = await storyblokApi.get('cdn/stories/home', {
	version: 'draft',
	resolve_links: 'link',
	language: 'es',
});

const story = data.story;
---

<Layout>
	<StoryblokComponent blok={story.content} />
</Layout>

Build a language switcher

Great. Now that everything is rendered correctly, we should allow the user to switch between different languages conveniently.

Let's start by making some modifications to src/pages/[...slug].astro. Here, we want to pass some props to src/layouts/Layout.astro, namely currentLanguage, and path.

src/pages/[...slug].astro
<Layout currentLanguage={language} path={slug.join('/')}>
	<StoryblokComponent blok={story.content} />
</Layout>

In pages/es/index.astro, we can just hardcode the values.

src/pages/es/index.astro
<Layout currentLanguage="es" path="">
	<StoryblokComponent blok={story.content} />
</Layout>

Next, in the layout, we can pass these props to a header component we'll create in a minute, and also set the lang attribute of the HTML document dynamically.

src/layouts/Layout.astro
---
import Header from '../components/Header.astro';
const currentYear = new Date().getFullYear();
const { currentLanguage, path } = 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" />
		<meta name="generator" content={Astro.generator} />
	</head>
	<body>
		<Header currentLanguage={currentLanguage} path={path} />
		<slot />
		<footer>All rights reserved © {currentYear}</footer>
	</body>
</html>

Now, we can create a src/components/Header.astro that contains the actual language switcher.

For the switching functionality, essentially, a list of links is rendered, with each link pointing to the respective language-specific paths of the page that is currently being visited. We can conveniently place the required logic in the front matter of the component. Importantly, for the homepage, we want to omit home from the href. Additionally, the default language is hardcoded into the languageLinks array, as it is not included in the languages array received from Storyblok via the getLanguageCodes helper function.

src/components/Header.astro
---
import {
	getLanguageCodes,
	getCleanSlug,
} from '../utils/i18n';
const { currentLanguage, story } = Astro.props;

const languages = await getLanguageCodes();

const storyFullSlug = story.default_full_slug || story.full_slug;
let baseSlug = '';

const homeSlugs = languages.map((lang) => `${lang}/home`);
homeSlugs.push('home');
if (!homeSlugs.includes(storyFullSlug)) {
	const slugData = await getCleanSlug(storyFullSlug);
	baseSlug = slugData.slug;
}

const additionalLanguageLinks = await Promise.all(
	languages.map(async (lang) => {
		return {
			lang,
			href: `${lang}/${baseSlug}`,
			active: lang === currentLanguage,
		};
	}),
);

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

<header>
	<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>
</header>

You may have already noticed a new function, getCleanSlug, being imported from the src/utils/i18n.js file. This function removes any language code prefix. As that may come in handy again, it makes sense to add it as a reusable helper function.

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

	const languages = await getLanguageCodes();

	let languageCode = '';

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

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

Finally, let's add some styles with a header layer.

public/styles/global.css
@layer header {
	header {
		display: flex;
		justify-content: flex-end;
		align-items: center;
		gap: 1rem;
		padding: max(2vh, 1rem) min(2vw, 2rem);

		details {
			position: relative;
			width: 180px;

			summary {
				list-style: none;
				padding: 0.5rem;
				background: white;
				cursor: pointer;
				border-radius: 12px;
				border: 2px solid var(--dark);
				color: var(--dark);
			}

			&[open] summary {
				border-bottom-left-radius: 0;
				border-bottom-right-radius: 0;
			}

			ul {
				position: absolute;
				top: 100%;
				left: 0;
				right: 0;
				margin: 0;
				padding: 0;
				background: white;
				list-style: none;
				z-index: 10;
				border-radius: 0 0 12px 12px;
				border: 2px solid var(--dark);
				border-top: none;
				overflow: hidden;

				li {
					margin: 0;
				}

				li a {
					display: block;
					padding: 0.5rem;
					text-decoration: none;
					color: black;

					&:hover {
						background-color: hsl(from var(--light) h s calc(l - 6));
					}

					&.active {
						font-weight: 600;
					}
				}
			}
		}
	}
}

Et voila, we have a language switcher!

Let's add a getInternalLink helper function to src/utils/i18n.js in order to omit home from the path for all language versions. Externalizing the logic for processing internal links for easy reusability is also more scalable and will come in handy in the next part of this tutorial series.

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

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

	let includeLanguage = '';
	if (languageCode) {
		includeLanguage = `${languageCode}/`;
	}

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

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

Let's use it in Button.astro as follows:

src/storyblok/Button.astro
---
import { storyblokEditable } from '@storyblok/astro';
import { getInternalLink } from '../utils/i18n';

const { blok } = Astro.props;

let url = '';
if (blok.link.linktype === 'story') {
	url = await getInternalLink(blok.link?.story?.full_slug);
} else if (blok.link.linktype === 'url') {
	url = blok.link?.url;
}
---

<a href={url} class="button" {...storyblokEditable(blok)}>
	{blok.label}
</a>

Account for translatable slugs

The Translatable Slugs app allows content editors to create and manage language-specific slugs. For example, you may want the path es/lp/landing-page-1 to be es/lp/pagina-principal-1 instead. Once you've created some translated slugs in your Storyblok space, let's consider how to implement these in our Astro project.

Each story object contains a translated_slugs array that contains a path key with the translated slug. If no translated slug has been provided, the default slug is used. Here's an example:

"translated_slugs": [
    {
      "path": "lp/pagina-principal-1",
      "name": "Pagina principal 1",
      "lang": "es",
      "published": false
    }
  ]

We can leverage this information to retrieve the correct slug dynamically based on the currentLanguage prop, searching the array for a match and returning the value defined for the path. If available, we get the translated slug; if not, we get the default slug. The first step is to make the full story object available in the header component. Therefore, let's make the following change to src/pages/[...slug].astro to pass it to the layout first.

src/pages/[...slug].astro
<Layout currentLanguage={language} story={story}>
	<StoryblokComponent currentLanguage={language} blok={story.content} />
</Layout>

Apply the same change to src/pages/es/index.astro. Next, let's update the layout to receive the prop and pass it to the header.

src/layouts/Layout.astro
---
import Header from '../components/Header.astro';
const currentYear = new Date().getFullYear();
const { currentLanguage, 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" />
		<meta name="generator" content={Astro.generator} />
	</head>
	<body>
		<Header currentLanguage={currentLanguage} story={story} />
		<slot />
		<footer>All rights reserved © {currentYear}</footer>
	</body>
</html>

Finally, we can update the language switcher as follows.

src/components/Header.astro
---
import {
	getLanguageCodes,
	getCleanSlug,
	getTranslatedSlug,
} from '../utils/i18n';
const { currentLanguage, story } = Astro.props;

const languages = await getLanguageCodes();

const storyFullSlug = story.default_full_slug || story.full_slug;
let baseSlug = '';

const homeSlugs = languages.map((lang) => `${lang}/home`);
homeSlugs.push('home');
if (!homeSlugs.includes(storyFullSlug)) {
	const slugData = await getCleanSlug(storyFullSlug);
	baseSlug = slugData.slug;
}

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

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

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

<header>
	<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>
</header>

Let's add the utility function getTranslatedSlug to src/utils/i18n.js.

src/utils/i18n.js
const getTranslatedSlug = async (story, language) => {
	if (!story.translated_slugs) return false;

	const translatedSlug = story.translated_slugs?.find((slug) => {
		return slug.path !== 'home' && slug.lang === language;
	});

	if (!translatedSlug) return false;
	return (await getCleanSlug(translatedSlug.path)).slug;
};

No changes are required to how we handle internal links, as the full_slug of the resolved story object (via the resolve_links parameter) matches the translated slug based on the language version of the original story that is requested from the API.

We can also leverage the translated_slugs array of the story object to generate alternate language links to the head of the HTML document, helping search engines serve the ideal URL based on the user's language preference.

Let's create a new src/components/AlternateLinks.astro. In here, we can build an array of objects with href and lang properties based on translated_slugs, skipping the currently active language. Additionally, if content is currently served in a language other than the default, we should push the default location to this new array.

src/components/AlternateLinks.astro
---
const { currentLanguage, story } = Astro.props;
const alternateLinks = (story?.translated_slugs || [])
	.filter((slug) => slug.lang !== currentLanguage)
	.map((slug) => {
		if (slug.lang === currentLanguage) return;
		const path = slug.path === 'home' ? '' : slug.path;
		return {
			href: `${Astro.site}${slug.lang}/${path}`,
			lang: slug.lang,
		};
	});

if (currentLanguage) {
	let defaultSlug = story.default_full_slug || story.full_slug;
	if (defaultSlug === 'home') defaultSlug = '';
	alternateLinks.push({
		href: `${Astro.site}${defaultSlug}`,
		lang: 'x-default',
	});
}
---

{
	alternateLinks &&
		alternateLinks.map(({ href, lang }) => (
			<link rel="alternate" href={href} hreflang={lang} />
		))
}

This new component can now be added to the main layout:

src/layouts/Layout.astro
---
import Header from '../components/Header.astro';
import AlternateLinks from '../components/AlternateLinks.astro';
const currentYear = new Date().getFullYear();
const { currentLanguage, 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} story={story} />
		<slot />
		<footer>All rights reserved © {currentYear}</footer>
	</body>
</html>

Build a static site

Everything we've done up until now will work just fine running locally or in Astro's server mode. However, should you want to benefit from the performance gains associated with Astro's static mode, there are some important changes to the dynamic src/pages/[...slug].astro route to be made.

To prerender all desired routes, Astro leverages the getStaticPaths function to determine which routes exactly to consider. See the Astro documentation for further details. Inside getStaticPaths, we can use Storyblok's links endpoint to retrieve an array of all content entries. As this includes folders, which we do not require for our purposes, we can intentionally omit these in the code. Importantly, we need to consider the alternates array of each link object to determine the slug for all different language versions. If the translated_slug equals home, it is intentionally omitted, as that route is already hardcoded using src/pages/es/index.astro.

Place the following getStaticPaths function right after the import statements.

src/pages/[...slug].astro
export async function getStaticPaths() {
	const languages = await getLanguageCodes();
	const storyblokApi = useStoryblokApi();

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

	const staticPaths = [];

	for (const link of links) {
		if (link.is_folder) continue;

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

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

	return staticPaths;
}

And that's it! With this additional logic in place, you can change the output to static in the Astro configuration and either run npm run build && npm run preview to test locally or deploy on your preferred hosting platform.

learn:

To keep things simple, we fetch the draft version of our content. In production, you would want to fetch only published content. Learn more in our tutorial on creating preview and production deployments.

hint:

As Astro already knows all the paths to generate now, you can generate a zero-config sitemap at this point. Just run npx astro add sitemap, provide a site in your astro.config.mjs, and the next build will include a sitemap. Pretty cool, huh?

Wrap up

Congratulations! You've successfully built a Storyblok + Astro website with multilingual support!

In the next part of the series, we'll explore the scenario of our startup expanding into the European market, creating a business need for stronger localization, and custom-tailoring the website content to a specific geographic target audience. Ready to go? Then let's get started and Build a Regionally Localized Website with Storyblok and Astro!

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.