If you know anything about static site generators, you’ve probably noticed a new favorite cropping up every year or so. Jekyll, Hugo, Eleventy, and countless others have all had their moments in the JAMstack spotlight, only to be replaced by something slightly shinier. But Astro.js seems to be here for the long haul.
What sets Astro apart is its “zero-JS by default” approach and its ability to scale with your needs—starting from the simplest possible static site and growing into a full-featured web application. This flexibility makes it both approachable for beginners and powerful enough for complex projects.
And like most web frameworks, Astro has a robust internationalization (i18n) story that makes creating multilingual websites straightforward. In this guide, we’ll begin a short series where we explore two complementary approaches to localization in Astro: static content translation using file-based routing and dynamic text management for interactive elements.
Before you start into this guide, here’s what you’ll need if you want to play along at home:
- Basic familiarity with Astro.js: If you’re new to Astro, check out the official docs for an overview.
- Node.js and npm installed: You’ll need them to run and build your project.
- A text editor: VS Code is a solid choice, but use whatever you’re comfortable with.
- The companion Git repo: Clone or explore the code at our Astro Localization GitHub Repo to follow along. We’re using the part-1 branch for this guide.
Two approaches to Astro localization
The beauty of Astro is that it never sets out to reinvent the wheel. Instead, it builds on common patterns and tooling. That makes it easy for just about anyone with a little web dev experience to pick up, including its approach to internationalization.
Specifically, Astro sites tend to divide internationalization into two approaches that you might recognize from other frameworks:
- Static content localization: This is built into Astro and helps you handle content that exists as complete pages, like blog posts, documentation, or marketing pages. Each language version lives in its own directory (such as /en/ or /fr/), giving you separate URLs for SEO and a clean content structure.
- Dynamic content localization: This is for handling all the small pieces of text that appear throughout your site: things like “Add to cart” buttons, form validation messages, or error notifications. Instead of creating separate files for each of these, you maintain translation files that can be swapped out based on the user’s language. However, you’ll need either a third-party library, such as astro-paraglide or your own solution to take care of this part.
Although both approaches are essential for creating a fully localized site, they work quite differently. That’s why we’re splitting this guide into two parts:
- Here, we’ll cover static content localization using file-based routing.
- And in part two we’ll cover UI text translation using astro-paraglide.
Getting set-up for route-based localization
Let’s build a multilingual recipes website. While our primary audience speaks English, we want to make the site accessible in other languages too. To reflect that, we’ll make English the default language, with localized versions.
Starting with a fresh Astro project, our first task is to configure language settings in astro.config.mjs:
import { defineConfig } from 'astro/config';
export default defineConfig({
i18n: {
defaultLocale: 'en', // The default language used as a fallback
locales: ['en', 'es', 'fr'], // All supported languages on the site
routing: {
prefixDefaultLocale: true // URL structure will be:
// /en -> English (default)
// /es -> Spanish
// /fr -> French
}
}
});
Code language: JavaScript (javascript)
This tells Astro three things: our supported languages, our default language (English), and how we want to structure our URLs. For example, Spanish content will live under /es/. We could put the default language at the root but that makes things a little trickier later on.
Now we have the minimum setup we need to get started.
Organizing content
Astro’s approach to content is pretty flexible, supporting YAML, JSON, Markdown, and external content management systems such as Directus and even WordPress.
For our recipe site, we’ll use Markdown. That lets us separate the recipe’s freeform content of ingredients and method from the standard metadata that we know we’ll need for each one.
Our first step is to define a recipe as a content type. We do this by defining what counts as valid recipe metadata. Astro makes this straightforward by using the Zod Typescript type definition and validation library.
Defining our recipe type
If you don’t have one already, create a content directory, and in there create a config.ts file:
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const recipes = defineCollection({
type: 'content',
schema: z.object({ // z.object() defines the shape of our frontmatter
name: z.string(),
category: z.string(),
description: z.string(),
complexity: z.string(),
prepTime: z.string(),
cookTime: z.string(),
ingredients: z.record(z.string(), z.array(z.string())),
image: z.string().optional()
})
});
export const collections = {
'recipes': recipes,
};
Code language: JavaScript (javascript)
In your terminal, run the Astro dev server:
npm run dev
Unless you already have something else running on that port, you’ll now be able to see your Astro site at http://localhost:4321
Keep the dev server running in the background as it will update your site with every change you make.
Organizing our recipes
Now that we’ve created the recipes content type, the next step is to add a recipes subdirectory within the content directory. If our site were monolingual, we could simply place all the recipe files directly into this subdirectory.
However, since we’re creating localized versions of each recipe, we need to organize them by language. To do this, we’ll create subdirectories for each locale like this:
content/
recipes/
en/
es/
fr/
So, if we create an apple crumble recipe, the English language version would live at content/recipes/en/apple-crumble.md
Creating our recipes
We already told Astro what counts as a valid recipe when we defined the content type. That definition applies to the frontmatter only, which means we get type safety and can catch errors early if something doesn’t match the expected structure.
This helps ensure that all the important metadata—like the category, description, and prep time—is present and won’t cause issues when we query or display it.
So, a valid apple crumble recipe might look something like this:
---
name: "Apple Crumble"
category: "Dessert"
description: "Warm, comforting dessert of baked apples with crispy topping"
complexity: "Easy"
prepTime: "20 minutes"
cookTime: "45 minutes"
ingredients:
filling:
- "6 large cooking apples, peeled and sliced"
- "100g brown sugar"
- "1 tsp cinnamon"
- "1/2 tsp nutmeg"
- "Juice of 1 lemon"
crumble:
- "200g plain flour"
- "100g butter, cold and cubed"
- "100g brown sugar"
- "50g oats"
- "Pinch of salt"
---
## Method
### Prepare the filling
Mix sliced apples with sugar, spices, and lemon juice. Arrange in a baking dish.
### Make the crumble
Rub butter into flour until mixture resembles breadcrumbs. Stir in sugar, oats, and salt.
### Bake
Sprinkle crumble mixture over apples. Bake at 180°C (350°F) for 45 minutes until golden and bubbling. Serve warm with ice cream or custard.
Code language: PHP (php)
And, of course, we also need localized versions in our es and fr directories, like so:
---
name: "Crumble aux pommes"
category: "Dessert"
description: "Dessert chaud et réconfortant de pommes cuites avec une garniture croustillante"
complexity: "Facile"
prepTime: "20 minutes"
cookTime: "45 minutes"
ingredients:
filling:
- "6 grosses pommes à cuire, épluchées et tranchées"
- "100g de sucre roux"
- "1 c. à café de cannelle"
- "1/2 c. à café de muscade"
- "Jus d’un citron"
crumble:
- "200g de farine"
- "100g de beurre, froid et coupé en cubes"
- "100g de sucre roux"
- "50g de flocons d'avoine"
- "Une pincée de sel"
---
## Méthode
### Préparer la garniture
Mélangez les tranches de pommes avec le sucre, les épices et le jus de citron. Disposez-les dans un plat à gratin.
### Préparer le crumble
Incorporez le beurre à la farine jusqu’à obtention d’un mélange ressemblant à de la chapelure. Ajoutez le sucre, les flocons d'avoine et le sel.
### Cuire
Saupoudrez le mélange de crumble sur les pommes. Faites cuire à 180°C (350°F) pendant 45 minutes jusqu’à ce que le dessus soit doré et bouillonnant. Servez chaud avec de la glace ou de la crème anglaise.
Code language: PHP (php)
You can find localized recipe files in the accompanying git repo.
Now let’s set up our pages.
Creating localized pages
If you’ve worked with static site generators before, Astro’s approach will feel familiar. It relies on a clear separation of:
- Layouts: Shared templates that handle things like headers, footers, and page structure.
- Pages: Files that define the URL routing and tie everything together.
- Components: Reusable building blocks for smaller pieces of functionality or design, which can be called from pages.
We’ll organize our pages in the same way as we did our content files, with a separate directory for each language.
So, our directory structure will look like this:
src/
pages/
en/
index.astro
es/
index.astro
fr/
index.astro
Each language gets its own index.astro file for now. It’s not the most efficient setup, but it’s a solid starting point and we’ll refine it as we go.
Defining the homepages
Our recipe site’s homepage needs to give just enough info about each recipe to help you decide which one to check out. It should also let you filter by category and switch languages.
Here’s what we’re aiming for:
You can also view this version of the recipes website on our demo site.
To set up our three localized homepages, we’ll use a base layout, an index.astro file for each locale, and a few components to keep things clean and minimize repetition.
Base layout
Our base layout is straightforward and keeps things clean. From a localization perspective, the key part is dynamically adapting to the current language.
This involves setting the lang attribute on the <html> tag and passing the current locale to components like the Header, so they can adjust content and links as needed.
Here’s how it looks:
---
import Header from '../components/header.astro';
import Footer from '../components/footer.astro';
// Get the current locale from the page calling this layout.
// This will help us dynamically adjust content or links based on the user's language.
const currentLocale = Astro.props.currentLocale;
---
<html lang={currentLocale}> <!-- Dynamically set the language attribute for the HTML element based on the current locale. -->
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Astro Gastro!</title> <!-- Localizing the title could be a good future enhancement. -->
</head>
<body>
<!-- Pass the current locale to the Header component so it can adjust content or links accordingly. -->
<Header currentLocale={currentLocale} />
<!-- Render the content specific to the current page. -->
<slot />
<!-- Include a shared footer, which we can localize later. -->
<Footer />
</body>
</html>
Code language: JavaScript (javascript)
Perhaps the most interesting parts of the base layout are how we handle the current locale.
We use Astro.props.currentLocale to determine the active language for the page, allowing us to adjust content and links to match.
For example, we pass currentLocale to the Header component, so it can display language-specific navigation and other localized elements. But we’ll come back to the Header component later on when we set up our language picker later.
Creating the index page(s) and fetching the current locale
Our index pages can be quite lightweight because most of the work is happening in the base layout and in the components that we call:
---
import BaseLayout from '../layouts/base.astro';
import RecipeGrid from '../components/recipeGrid.astro';
import ListingHeader from '../components/listingHeader.astro';
const currentLocale = Astro.currentLocale; // Get the current locale (e.g., 'en', 'es', 'fr')
---
<BaseLayout currentLocale={currentLocale}>
<ListingHeader />
<RecipeGrid currentLocale={currentLocale} />
</BaseLayout>
Code language: JavaScript (javascript)
It’s worth mentioning Astro.currentLocale. This is Astro’s built-in way of giving us the current locale, based on the URL structure of the page. For example, if the URL is /es/recipes, Astro.currentLocale will return es.
This makes it easy to use the locale throughout your site—for setting the lang attribute, filtering content, or updating links—without needing additional logic to figure out the active language. And we pass it both to the base layout and the RecipeGrid component.
Displaying the list of recipes
Most of the homepage’s functionality lives in the RecipeGrid component. This is where we pull recipes from the content collection and display them as individual cards. Tailwind CSS handles the layout, giving us a clean, responsive grid without much effort.
The key localization challenge here is making sure users only see recipes in their selected language. To do this, we need to figure out the language of each recipe. Astro helps us by giving each recipe a unique ID based on its file path, like en/apple-crumble for English or fr/tarte-aux-pommes for French.
Here’s how it works:
- Extract the locale: We split the recipe ID by / and take the first part (en, es, fr, etc.). This tells us the language of the recipe.
- Filter by locale: Once we know the current page’s language (from Astro.currentLocale), we filter the recipes to match.
- Map to cards: The filtered recipes are then turned into cards, showing key details like the name, description, and prep time.
- Layout the grid: Tailwind takes over here, making sure the cards look good on any screen size.
By extracting the locale directly from the recipe ID, we avoid hardcoding anything or needing extra metadata in our files. It’s simple, reliable, and keeps everything in sync.
---
import { getCollection } from 'astro:content';
import BaseLayout from '../../../layouts/base.astro';
import RecipeHeader from '../../../components/recipeHeader.astro';
import IngredientsList from '../../../components/ingredientsList.astro';
export const prerender = true; // Build pages statically for all supported locales.
export async function getStaticPaths() {
// Fetch all recipes, including localized versions, from the content collection.
const allRecipes = await getCollection('recipes');
// Create a route for each recipe in every language.
return allRecipes.map((recipe) => {
// Extract the locale (e.g., "en", "es", "fr") from the recipe ID.
// Recipe IDs are structured with the locale as the first part of the path,
// like "en/apple-crumble" or "fr/tarte-aux-pommes". By splitting the ID,
// we can identify the language of each recipe. This step is crucial because
// it allows us to match recipes to the correct locale and ensure users only see
// content in their selected language.
const locale = recipe.id.split('/')[0];
return {
params: {
locale, // Pass the locale to the route for proper localization.
// Clean up the recipe slug by removing the locale prefix.
slug: recipe.slug.replace(`${locale}/`, '')
},
props: { recipe } // Pass the recipe data to the page to render it.
};
});
}
// Get the localized recipe data and the current locale for this page.
const { recipe } = Astro.props;
const currentLocale = Astro.currentLocale; // Use Astro to get the current locale.
// Render the content body of the recipe markdown file.
const { Content } = await recipe.render();
---
<BaseLayout currentLocale={currentLocale}>
<article class="max-w-5xl mx-auto px-4 py-12">
<!-- Render the recipe header with localized details like name, description, and timings. -->
<RecipeHeader
name={recipe.data.name}
description={recipe.data.description}
prepTime={recipe.data.prepTime}
cookTime={recipe.data.cookTime}
complexity={recipe.data.complexity}
/>
<!-- Set up a two-column layout: ingredients on one side, instructions on the other. -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 mt-10">
<aside class="md:col-span-1 bg-gray-50 dark:bg-gray-800 p-6 rounded-lg shadow">
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-4">
Ingredients
</h2>
<!-- Use the localized ingredients list in the IngredientsList component. -->
<IngredientsList ingredients={recipe.data.ingredients} />
</aside>
<section class="md:col-span-2 prose prose-emerald dark:prose-invert max-w-none leading-relaxed">
<!-- Render the localized recipe instructions from the markdown content. -->
<Content />
</section>
</div>
</article>
</BaseLayout>
Code language: JavaScript (javascript)
Creating a language picker
We want our visitors to be able to pick their language and then stay within that language across the site. To do that, we need to create a language picker that:
- Knows which languages are available.
- Uses client-side JavaScript to redirect to the chosen language.
Astro gives us access to the configured languages through the getRelativeLocaleUrlList() function. We can use that to create a language picker that will automatically adapt if we add or remove languages.
First, we’ll create a new Astro component:
---
// Import Astro's i18n routing utility
import { getRelativeLocaleUrlList } from 'astro:i18n';
// Get the current locale and the list of all locale URLs
const currentLocale = Astro.currentLocale;
const localeUrls = getRelativeLocaleUrlList();
---
<select
id="language-picker"
class="bg-gray-100 text-gray-700 text-sm font-medium rounded-lg px-4 py-2 pr-8 hover:bg-gray-200 transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500"
aria-label="Select language"
>
{localeUrls.map((url) => {
const locale = url.split('/')[1] || 'en'; // Default locale if no prefix
return (
<option value={locale} selected={currentLocale === locale}>
{locale.toUpperCase()}
</option>
);
})}
</select>
<!-- Link to the JavaScript file for interactivity -->
<script src="/js/languagePicker.js" defer>
</script>
Code language: HTML, XML (xml)
It generates a dropdown menu based on your configured languages and marks the current language as selected. Astro’s i18n utilities make this part straightforward.
Next, we’ll handle what happens when someone picks a new language. The dropdown needs JavaScript to detect the selection and redirect the browser to the correct language-specific URL.
So, we’ll create a separate script that runs client side:
document.addEventListener('DOMContentLoaded', () => {
const languagePicker = document.getElementById('language-picker');
if (languagePicker) {
languagePicker.addEventListener('change', (event) => {
const selectedLocale = event.target.value;
// Strip the current locale from the path
const currentPath = window.location.pathname;
const pathWithoutLocale = currentPath.replace(/^\/[^/]+/, '');
// Build the new URL with the selected locale
const newPath = `/${selectedLocale}${pathWithoutLocale}`;
// Redirect the browser
window.location.href = newPath;
});
}
});
Code language: JavaScript (javascript)
Once we add that into our header, clicking a locale in the dropdown takes us to the equivalent page for that language.
There’s still quite a bit of work to do. Notice how even though the recipe card is in Spanish, the rest of the page is in English? That’s what we’ll be covering in the next installment of this miniseries with astro-paraglide.
A quick word about dynamic language redirection
Astro is still primarily a static site generator, and that’s how we’re using it for our recipes website. That means our site can’t handle dynamic features like detecting a visitor’s locale and redirecting them to the correct language page out of the box.
Instead, we’d need to add something extra—like client-side JavaScript to check the user’s browser language or server-side cloud functions. If that’s the route you’d prefer to take, you can read more in Astro’s docs on how to use browser locale detection in dynamic Astro sites.
For now, we’ll handle things manually. We’ll present the English version by default and offer a drop-down picker so that our visitors can choose a different language.
Dynamic language routes
So far, we’ve been manually creating separate directories for each language. That means we have one homepage for English, another for French, and another for Spanish, along with separate versions of other pages, like individual recipes.
While this approach is easy to understand, it has a couple of major drawbacks:
- It’s repetitive: We’re duplicating the same markup and code across multiple languages.
- It’s harder to maintain: Adding or removing a language means updating multiple directories and files, which quickly becomes tedious.
This is exactly where dynamic routing comes in. If you’re familiar with Astro, you’ve probably guessed that we can use dynamic routes to simplify things. If you’re new to Astro, don’t worry—we’ll break it down.
Using a [locale] directory
Instead of creating separate directories like /en, /es, and /fr, we can use a single [locale] directory. This directory acts as a dynamic route, automatically generating pages for each language at build time.
Inside the [locale] directory, we’ll include:
- An index.astro file to handle localized homepages.
- A recipes directory containing the [slug].astro file for individual recipe pages.
The new structure looks like this:
src/pages/
[locale]/
index.astro // Handles localized homepages
recipes/
[slug].astro // Handles localized recipe pages
Code language: JavaScript (javascript)
We’re going to replace the individual language homepages with a single index.astro that dynamically generates the homepage for each supported language during the build process.
Here’s how it works:
- Astro identifies the languages to build: The getStaticPaths function tells Astro which language pages to create based on the locales you’ve configured (e.g., en, es, fr).
- [locale] acts as a dynamic placeholder: The [locale] directory is a dynamic route. During the build, Astro replaces [locale] with the actual language codes, generating /en/, /es/, and so on.
- Localized content: Astro ensures that the correct content is displayed for each language by matching the current locale to the content collection. For example, recipes in the en directory are shown on the English homepage, and recipes in the es directory are shown on the Spanish homepage.
This approach simplifies the site structure and removes the need for separate directories like /en, /es, and /fr. Everything is now handled in one file, making it easier to add or remove languages without duplicating code.
To generate our locale-specific homepages dynamically, we’ll use Astro’s getStaticPaths function.
Here’s the key part:
export async function getStaticPaths() {
// Use Astro's i18n utility to get a list of locale-specific paths
// This will return an array like: ['/en', '/es', '/fr']
const paths = getRelativeLocaleUrlList();
// Transform the paths into the format required by Astro
return paths.map((url) => {
// Extract the locale from the path. This assumes every locale has a prefix.
const locale = url.split('/')[1]; // Extracts 'en', 'es', 'fr', etc.
// Return an object with the locale as a route parameter for Astro to generate the page
return { params: { locale } };
});
}
Code language: JavaScript (javascript)
If you’ve used Astro before, you’ll know that getStaticPaths creates pages based on parameters you provide. Here, it’s generating a homepage for each language by extracting the locale (like en or es) from the configured paths.
Creating localized recipe pages
We’ll use a similar technique to generate the localized page for each recipe, which will live at /pages/[locale]/recipes/[slug].astro. This approach dynamically creates a page for every recipe in every language during the build process.
The getStaticPaths function does the heavy lifting here. It extracts the locale from each recipe’s id (e.g., en/apple-crumble) and maps it to a clean URL like /en/recipes/apple-crumble or /fr/recipes/crumble-aux-pommes.
To achieve this, we explicitly remove the locale prefix from the slug using this line:
slug: recipe.slug.replace(`${locale}/`, '')
Code language: JavaScript (javascript)
This step is pretty important because our content directory includes the locale in the file path (content/recipes/en/apple-crumble.md), and the slug inherits this structure.
Without cleaning it up, we’d end up with URLs like /en/recipes/en/apple-crumble, which are unnecessarily repetitive.
Here’s the overall process:
- Fetch recipes: getCollection(‘recipes’) retrieves all recipes, including their language-specific versions.
- Generate routes: For each recipe, we extract the locale, clean up the slug, and pass both as parameters so Astro knows where to build the pages.
- Pass the data: The recipe data is provided as props, allowing the page to render everything it needs.
The rest of the page is straightforward. The layout (BaseLayout) takes care of the overall structure, while components like RecipeHeader and IngredientsList display the recipe-specific details.
The recipe’s markdown is rendered to show instructions, keeping everything localized and neatly presented.
Now we can visit the localized version of our homepage and click through to a localized version of each recipe.
Dynamic links
Now that we’ve set up a more efficient way to generate our localized pages, it’s time to address another key issue: the links in our recipe cards.
Right now, these links are hardwired, meaning they always point to a single, non-localized version of each recipe.
For example, if you’re on the Spanish homepage, clicking on a recipe might still take you to the English version. That’s not what we want in a multilingual site.
The problem lies in the slug: it includes the locale (e.g., en/bruschetta), so links end up looking like /en/recipes/en/bruschetta/. This creates redundant URLs and breaks the user experience.
To fix it, we clean up the slug in RecipeGrid by removing the locale prefix before passing it to RecipeCard:
const localizedLink = getRelativeLocaleUrl(currentLocale, `recipes/${slug}`);
Code language: JavaScript (javascript)
With the cleaned slug, RecipeCard uses Astro’s getRelativeLocaleUrl() to generate the correct localized link dynamically:
const localizedLink = getRelativeLocaleUrl(currentLocale, `recipes/${slug}`);
Code language: JavaScript (javascript)
Next up, localized UI text
We now have a multilingual recipe site using Astro’s file-based routing. Translations are organized, links dynamically adapt to the user’s chosen language, and the language picker makes switching locales simple.
That said, the UI text—like menus, buttons, and labels—remains in English. In part two of this mini-series, we’ll tackle localizing those elements to create a fully multilingual experience.
Localization solutions
for developers
Automate the software localization process to eliminate
hassle and manual work, and seamlessly integrate
localization into your development cycle.