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!
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:
banner
headline
: text field, checked as translatablebuttons
: blocks field, limited to accept a maximum of twobutton
blocks
image_text
image
: single asset field, checked as translatableheadline
: text field, checked as translatabletext
: rich text field, checked as translatablebuttons
: blocks field, limited to accept a maximum of twobutton
blocks
button
label
: text field, checked as translatablelink
: link field, checked as required and translatable
These nestable blocks should be used in combination with the page
content type included by default.
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.
Eager to see the complete implementation already? Take a look at the tutorial repository on GitHub.
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',
image_text: 'storyblok/ImageText',
button: 'storyblok/Button',
},
}),
],
output: 'server',
vite: {
plugins: [mkcert()],
},
});
Hereafter, let's add them to the src/storyblok
folder.
---
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>
---
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>
---
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.
---
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.
---
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>
@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
Not fancying any content editing work? Just duplicate the reference space to your account, with sample content ready to go. Just remember to swap the access token in your Astro project.
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
storylp
folderlanding-page-1
storylanding-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.
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.
---
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).
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 simply 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!

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.
---
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
.
<Layout currentLanguage={language} path={slug.join('/')}>
<StoryblokComponent blok={story.content} />
</Layout>
In pages/es/index.astro
, we can just hardcode the values.
<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.
---
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. Here, our i18n.js
utility comes in handy again, allowing us to retrieve all language codes. 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. This can be done programmatically using Astro's .map()
function, with the benefit of additional locales being included immediately.
---
import { getLanguageCodes } from '../utils/i18n';
const { currentLanguage, path } = Astro.props;
const languages = await getLanguageCodes();
---
<header>
<label id="language-switcher">Choose a language:</label>
<details aria-labelledby="language-switcher">
<summary>{currentLanguage || 'en'}</summary>
<ul>
<li>
<a href={`/${path}`} class={!currentLanguage ? 'active' : ''}>en</a>
</li>
{
languages.map((lang) => (
<li>
<a
href={`/${lang}/${path}`}
class={lang === currentLanguage ? 'active' : ''}
>
{lang}
</a>
</li>
))
}
</ul>
</details>
</header>
Finally, let's add some styles with a header layer.
@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!
Handle internal links
One more thing we have to consider is that internal links should be handled so that the currently active language is preserved. For instance, when visiting es/lp/landing-page-1
, and one of the buttons contains a link to the other landing page, it shouldn't direct to lp/landing-page-2
, but es/lp/landing-page-1
. To accomplish that, let's proceed by also passing the currentLanguage
prop to Storyblok components in Astro.
First, add it to StoryblokComponent
in src/pages/[...slug].astro
.
<Layout currentLanguage={language} path={slug.join('/')}>
<StoryblokComponent currentLanguage={language} blok={story.content} />
</Layout>
Subsequently, you can repeat that process in src/pages/es/index.astro
, src/storyblok/Banner.astro
, and src/storyblok/ImageText.astro
, making sure currentLanguage
reaches src/storyblok/Button.astro
.
Finally, currentLanguage
can be used to prepend the language code to the link (unless it's undefined
, i.e., the default language is active). Note that in the code below, we're also accounting for internal links to the home story, directing these to the index rather than /home
.
---
import { storyblokEditable } from '@storyblok/astro';
const { currentLanguage, blok } = Astro.props;
let url;
if (blok.link.linktype === 'story') {
const slug = blok.link?.story?.full_slug;
url = currentLanguage ? `/${currentLanguage}/` : '/';
url += slug === 'home' ? '' : 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 for 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.
<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.
---
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.
---
import { getLanguageCodes, getI18nStorySlug } from '../utils/i18n';
const { currentLanguage, story } = Astro.props;
const languages = await getLanguageCodes();
const path = story.default_full_slug === 'home' ? '' : story.default_full_slug;
---
<header>
<label id="language-switcher">Choose a language:</label>
<details aria-labelledby="language-switcher">
<summary>{currentLanguage || 'en'}</summary>
<ul>
<li>
<a href={`/${path}`} class={!currentLanguage ? 'active' : ''}>en</a>
</li>
{
languages.map((lang) => (
<li>
<a
href={`/${lang}/${getI18nStorySlug(lang, story)}`}
class={lang === currentLanguage ? 'active' : ''}
>
{lang}
</a>
</li>
))
}
</ul>
</details>
</header>
By passing the complete story object as well as the language code for each specific link to a utility function, either the translated slug for that story, or, if unavailable, the default slug for that language version of the story, is returned. Let's add that utility function to 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;
};
const getI18nStorySlug = (currentLanguage, story) => {
const translatedSlug = story.translated_slugs.find((slug) => {
return slug.path !== 'home' && slug.lang === currentLanguage;
});
const defaultSlug =
story.default_full_slug === 'home' ? '' : story.default_full_slug;
const slug = translatedSlug?.path ?? defaultSlug;
return slug;
};
export { getLanguageCodes, getI18nStorySlug };
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.
Deploy 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.
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.
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.
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?
Outlook
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. Stay tuned!