Development & Building with AI · Tutorial
How to build an international website with Astro
A multilingual website starts with URL decisions, not translation. See how Astro organizes languages, when to use its built-in routing and when to build your own.
- Author:
- Redação Token & Tech
- Responsible editor:
- Daniel Ferreira
- Translation:
- Redação Token & Tech
Published · 7 min read
Originally published in Brazilian Portuguese.
Translated from the Portuguese original with AI assistance.
Also available in: Português
Key points
- Each language needs its own URL; Google advises against automatically redirecting based on the visitor's language or IP.
- Astro's built-in i18n routing handles prefixes and links, but requires the same file structure in every language.
- For translated slugs and pages only where a real translation exists, a dynamic route with getStaticPaths gives full control.
You have a website in Portuguese and want to publish it in English too. The first temptation is to install an automatic translator and move on. The problem is that search engines and readers need to find each version at a stable address, with the language clearly declared.
This tutorial shows how to build that foundation with Astro, a framework that generates websites from components and, by default, delivers static HTML. The code examples are minimal and meant for illustration. Always check the documentation for the version you use.
At the end, we describe how Token & Tech itself is built, as a documented case. The international SEO part, with hreflang and x-default, is covered in detail in the companion article internationalization and hreflang in Astro projects.
Start with the URL structure
Internationalization (abbreviated i18n, because there are 18 letters between the “i” and the “n”) means preparing the site to work in several languages. The first decision is where each language will live.
Google’s guide on multi-regional and multilingual sites compares four options:
- Country domain (example.de): clear geographic signal, but higher cost and infrastructure.
- Subdomain (de.example.com): simple to set up, but readers may not know whether “de” indicates a language or a country.
- Subdirectory (example.com/de/): easy to maintain, everything on a single server.
- URL parameter (example.com?loc=de): Google does not recommend it.
For most small and medium projects, subdirectories such as /en/ and /pt-br/ are the simplest path. It is the model used in this tutorial.
The same guide makes two recommendations that affect the whole project. Google recommends using different URLs for each language version, instead of switching content by cookie or browser settings. And it advises against automatically redirecting visitors based on a guess about their language, because this can prevent people and crawlers from reaching all versions. The text notes that Google’s crawler usually crawls from the United States and without sending the Accept-Language header, which tells the site the browser’s preferred language. The alternative is to offer visible links to the other versions.
Option 1: Astro’s built-in i18n routing
Astro has built-in internationalization routing. You declare the languages and the default language in the configuration file. An example:
// astro.config.mjs: minimal example
import { defineConfig } from "astro/config";
export default defineConfig({
site: "https://example.com",
i18n: {
locales: ["en", "pt-br"],
defaultLocale: "en",
routing: {
prefixDefaultLocale: true,
},
},
});
With prefixDefaultLocale: true, even the default language gets a prefix. Pages go into folders with the same names as the languages:
src/pages/en/index.astro → /en/
src/pages/en/about.astro → /en/about/
src/pages/pt-br/index.astro → /pt-br/
src/pages/pt-br/about.astro → /pt-br/about/
According to the documentation, the folder names must exactly match the values in locales. Without the prefix on the default language (the original behavior), that language’s files go in the root of src/pages/.
The astro:i18n module offers functions to build links without writing paths by hand. An example:
---
// example: link to the Portuguese version of the "about" page
import { getRelativeLocaleUrl } from "astro:i18n";
const href = getRelativeLocaleUrl("pt-br", "about");
---
<a href={href} lang="pt-BR">Português (Brasil)</a>
In pages, Astro.currentLocale reports the language of the current URL, including on static pages. Astro.preferredLocale, which reads the browser’s preference, is tied by the documentation to on-demand rendered pages, that is, pages generated on the server on each request.
There are other options, each with a minimum version stated in the documentation: fallback (show another language when a page is missing, by redirect or rewrite), routing: "manual" (turns off the i18n middleware and leaves the logic to you) and domains (one domain per language, which requires server rendering and an adapter).
The limit of the built-in model
The documentation states that the file structure and the URL structure must be the same in every language. This means /en/about/ and /pt-br/about/ use the same file name. If you want /pt-br/sobre/, built-in routing alone does not solve it. The guide does not cover translated slugs.
Another point: the fallback option can create pages that redirect to or display content from another language. On an editorial site, this can put English text under a Portuguese URL. Decide consciously whether you want this behavior.
Option 2: dynamic routes with translated slugs
The documentation itself has a recipe, Add i18n features, that does not use the built-in routing. It combines content collections, interface dictionaries and dynamic routes, and shows a map of translated routes.
A dynamic route is a file with square brackets in its name. According to the routing guide, a rest parameter such as [...path] accepts paths of any depth. In static mode, every route must be known at build time, which is why the file exports getStaticPaths().
Step 1: store each translation as a record
In a content collection, each text has a language, its own slug, an identifier shared by its translations and a status. Example:
// src/content.config.ts: minimal example
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";
const posts = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/posts" }),
schema: z.object({
title: z.string(),
locale: z.enum(["en", "pt-br"]),
slug: z.string(), // slug in the text's language
group: z.string(), // same value across all translations
status: z.enum(["draft", "published"]),
}),
});
export const collections = { posts };
The group field is what links /en/what-is-ai/ to /pt-br/o-que-e-ia/. You will need it for the language switcher and for hreflang.
Step 2: generate only the pages that exist
---
// src/pages/[...path].astro: minimal example
import { getCollection, render } from "astro:content";
export async function getStaticPaths() {
const posts = await getCollection("posts", (p) => p.data.status === "published");
return posts.map((post) => ({
params: { path: `${post.data.locale}/${post.data.slug}` },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
const htmlLang = post.data.locale === "pt-br" ? "pt-BR" : "en";
---
<html lang={htmlLang}>
<head><title>{post.data.title}</title></head>
<body><Content /></body>
</html>
The filter in getCollection leaves drafts out. If a text exists only in Portuguese, no English page is generated for it. There is no empty page and no content from another language in its place.
Step 3: declare the language in the HTML
The W3C, the consortium that maintains web standards, recommends always declaring the language in the lang attribute of the html tag. Passages in another language get their own lang, such as <span lang="en">. The same text advises against using the Content-Language meta tag for this and mentions the dir attribute for languages written right to left, such as Arabic.
Step 4: build an honest language switcher
The switcher lists the versions that exist for that page, using the group. When there is no translation, take the reader to that language’s home page, instead of a broken link.
Write the name of each language in full, in the language itself. The W3C, in its text on how to indicate the language of a link, generally recommends avoiding flags, because flags represent countries, and countries do not correspond to languages.
Step 5: handle the site root
The / address has to lead somewhere. On static sites, the Astro recipe uses a meta refresh to /en/. If your server allows it, a fixed server-side redirect also works. Either way, the destination should always be the same, without guessing the visitor’s language.
Step 6: hreflang and sitemap
With the translation map ready, each page can list its versions in other languages in the <head> and in the sitemap. Google’s rules for this are strict, such as the requirement that pages reference each other. They are explained in the article on hreflang. Google’s documentation on localized versions is the primary reference.
Which option to choose
In the editorial team’s assessment, the choice depends on two questions:
- Do the slugs need to be translated? If
/pt-br/about/is acceptable, built-in routing is simpler and comes with ready-made functions. - Does every page exist in every language? If not, prefer generating pages only where a translation exists, instead of relying on
fallback.
Nothing prevents mixing the approaches. The Astro recipe, for example, uses per-language folders together with dynamic routes.
Documented case: how Token & Tech is structured
The Token & Tech website, the publication you are reading, is built with Astro and follows option 2. The following description comes from the project’s own technical documentation.
- Static output. The site is generated as HTML at build time, with a trailing slash on every URL.
- Central router. A single module builds the list of all pages. Each page has a path, a language, a group identifier shared by its translations and an indication of whether it should be indexed. The file
src/pages/[...path].astrorenders that list. The language switcher, hreflang and sitemap read the same list, so they cannot diverge. - No built-in i18n router. The project’s configuration records that this choice was made to allow translated slugs per language and to generate pages only where a real translation exists.
- Portuguese at the root. Portuguese, the original language, lives at the root with no prefix; other languages use a prefix such as
/en/. Segments and slugs are translated: the development section is/desenvolvimento-e-ia-aplicada/in Portuguese and/en/building-with-ai/in English. Old addresses starting with/pt-br/permanently redirect to the root. - No borrowed language. An article only appears in a language when the translation for that language is published. If there is no equivalent version, the switcher leads to the home page of the chosen language.
- Fixed root, no language-based redirect. The
/address always opens the Portuguese edition, with no decision based on IP orAccept-Language. When the visitor’s browser is set to another published language, a discreet bar offers to switch, and the choice is remembered on the device. - Reciprocal hreflang. Each indexable page lists all its indexable translations and an
x-defaultpointing to the Portuguese version, in the<head>and in the sitemap.
As of October 2026, English and Brazilian Portuguese are live. Six other languages have interface texts and URLs prepared, but switched off until reviewed by native speakers.
Key takeaways
- Decide the URL structure before translating. Per-language subdirectories are the simplest option to maintain.
- Use Astro’s built-in i18n routing when file names can be the same in every language.
- Use
getStaticPathsand a collection withlocale,slugandgroupwhen you need translated slugs and pages only where a translation exists. - Declare the language with
lang, offer visible links between versions and do not redirect based on the visitor’s presumed language.
Disclosure: The Token & Tech website is built with Astro and uses the multilingual architecture described here.
Sources
- Internationalization (i18n) Routing, Astro Docs. Accessed October 8, 2026
- Add i18n features, Astro Docs. Accessed October 8, 2026
- Routing, Astro Docs. Accessed October 8, 2026
- Content collections, Astro Docs. Accessed October 8, 2026
- Managing multi-regional and multilingual sites, Google Search Central. Accessed October 8, 2026
- Tell Google about localized versions of your page, Google Search Central. Accessed October 8, 2026
- Declaring language in HTML, W3C Internationalization. Accessed October 8, 2026
- Indicating the language of a link destination, W3C Internationalization. Accessed October 8, 2026