Table of contents Show Hide
- The mental model
- Opt-in, off by default
- What you get when you flip it on
- Step 1 — enable the feature
- Step 2 — translate your content
- Blog posts — just add files
- Projects — same story
- The built-in pages — translate the dictionary
- Your own pages — one file per locale
- Step 3 — translate the built-in UI strings
- Strings vs. structured copy: t() and tData()
- Adding a third language
- Step 4 — localize the navigation, logo, and footer
- How URLs work
- SEO checklist
- Switching the default language
- Turning it back off
- What’s next
Astro Rocket ships native, opt-in internationalization. You can run your site in two — or twenty — languages without leaving the theme: no scaffolding CLI, no plugins, no separate package. The system localizes your pages, the whole blog, the projects section, the navigation, the logo, and every built-in UI string — right down to the search dialog and its runtime messages — all generated at build time.
This is the complete, current guide: what the feature is, what it costs (nothing if you leave it off), and exactly how to turn it on and ship an English-plus-Dutch site.
Kept current. The i18n system has matured a lot since it first shipped, much of it from community feedback. Two things changed that earlier versions of this guide got wrong: extra language dictionaries are now auto-loaded (you no longer edit
src/i18n/index.tsto register them), and the built-in pages — Home, About, Services, Contact — are now localized from the dictionary, not by hand-copying page files. If you previously createdsrc/pages/<locale>/about.astrocopies, delete them; they now collide with the generated locale routes.
If you want the broader picture of how the theme is structured first, the configuration guide covers site.config.ts, themes, and layout switches; the SEO post covers everything in the <head> that i18n extends with hreflang.
The mental model
Five pieces make up the system. Once you know what each one does, the rest of this post is just turning them on:
- One config file (
src/config/i18n.config.ts) — the master switch and your list of locales. - Locale-prefixed routing — Astro’s native i18n routing, wired in conditionally. Your default locale stays at the root; the rest live under a prefix like
/nl/. - JSON dictionaries (
src/i18n/<locale>.json) — read through at()/tData()helper. They hold both the shared UI strings and the copy for the built-in pages. - Locale-aware content collections — your blog and projects carry a
localefield and live in per-locale folders. - Automatic localized routes — once a locale exists, the theme generates that locale’s built-in pages, blog, and projects for you, with every internal link staying inside the locale.
Everything is resolved at build time. There’s no client-side routing, no framework hydration, and no language-detection middleware — so turning i18n on doesn’t change Astro Rocket’s performance posture.
Opt-in, off by default
The whole feature is gated behind a single flag in src/config/i18n.config.ts:
const i18nConfig: I18nConfig = {
enabled: false,
defaultLocale: 'en',
locales: ['en'],
// …
};
When enabled is false — the default — the build is byte-for-byte identical to a single-locale site. No LanguageSwitcher renders, no hreflang tags emit, no /<locale>/ routes are generated, no JS for locale handling ships. Existing Astro Rocket sites upgrading see zero change. (The gate is precise: i18n activates only when enabled: true and locales has at least two entries.)
Turn it on and the whole machinery wakes up.
What you get when you flip it on
As soon as i18n is active:
- Locale-prefixed routes. With
prefixDefaultLocale: false(the default), your default locale stays at the site root — the English “About” is still/about. Additional locales live under a prefix — a Dutch “About” is/nl/about. - A
LanguageSwitcherdropdown in the header and mobile menu, automatically. It lists every configured locale and links to the matching version of the page the visitor is on. - The built-in pages in every locale — Home, About, Services, and Contact render from the dictionary, so they light up the moment a locale is configured (translated or falling back to your default copy).
- An automatically localized blog — index, posts, pagination, and tag archives — for every locale you add content to. No per-locale page files to write.
- An automatically localized projects section, the same way.
- Localized chrome — navigation, logo, footer, the theme and search controls, forms, the 404 page, and every shared UI string follow the active locale.
hreflangalternates (plus anx-default) in the<head>of every page, pointing at each locale’s real version of that URL per Google’s recommendation.
Step 1 — enable the feature
Open src/config/i18n.config.ts and switch on the flag and the locales you want. Here’s the config for English plus Dutch:
const i18nConfig: I18nConfig = {
enabled: true,
defaultLocale: 'en',
locales: ['en', 'nl'],
localeNames: {
en: 'English',
nl: 'Nederlands',
},
detectBrowserLocale: false,
};
Three things to know:
defaultLocaleis the locale served at the site root. Keep it'en'and English visitors keep landing at/,/about,/blog. (Making a non-English language the default is fully supported — see Switching the default language below.)localesis the master list. Order doesn’t affect routing, but it controls the order of items in theLanguageSwitcherdropdown.localeNamesare the display labels in the dropdown. Use the language’s own name (Nederlands, not “Dutch”) — that’s standard practice and respectful to visitors switching from a language they can read.
Save the file. The LanguageSwitcher appears in your header, and — because Astro Rocket ships a full Dutch dictionary — /nl, /nl/about, /nl/services, and /nl/contact already render in Dutch. Your blog and projects show a localized (but empty) /nl/blog and /nl/projects index until you add content in that locale, which is the next step.
Step 2 — translate your content
Content in Astro Rocket falls into two tiers, and the difference is the whole trick to understanding i18n here:
- Auto-localized — the built-in pages, blog, and projects. You translate copy (or drop in files); the routing is automatic.
- Manual — only your own custom pages. Those are plain Astro page files, so you create one per locale.
Blog posts — just add files
Astro Rocket’s collections carry a locale field on their schema (src/content.config.ts). Translated content lives in a parallel folder structure:
src/content/blog/en/hello-world.mdx
src/content/blog/nl/hallo-wereld.mdx
In the Dutch post’s frontmatter, set locale: nl. The schema validates that field against the locales list in i18n.config.ts, so any locale you register there is accepted automatically — there’s no separate enum to edit.
That’s the entire job. You do not create any blog page files. Drop posts in src/content/blog/nl/ and the theme generates the whole localized blog for that locale:
| URL | What it is |
|---|---|
/nl/blog |
the index |
/nl/blog/<slug> |
each post |
/nl/blog/page/N |
pagination |
/nl/blog/tag/<tag> |
tag archives |
Every in-locale link — post cards, tag chips, pagination, breadcrumbs, related posts — resolves inside that locale instead of falling back to the default-locale URL. The defaultLocale keeps its prefix-free URLs (/blog). A locale with no posts yet still renders an empty /<locale>/blog index, so the LanguageSwitcher never lands on a 404.
Projects — same story
Projects work exactly like the blog. Drop translations in src/content/projects/<locale>/ (the bundled projects live in src/content/projects/en/) and the localized projects section is generated for you: the index (/nl/projects), each project (/nl/projects/<slug>), pagination (/nl/projects/page/N), and tag archives (/nl/projects/tag/<tag>).
Projects share one slug across locales — keep the same filename in each locale folder (e.g. en/studio-portfolio.mdx ↔ nl/studio-portfolio.mdx) and the theme pairs them automatically for hreflang and the LanguageSwitcher. (Blog posts can instead use locale-specific slugs paired by a canonical uid — more on that below.)
The built-in pages — translate the dictionary
Home, About, Services, and Contact are the least work of all, because they don’t have per-locale files at all. Each one is a thin route that renders a shared View component (AboutView, ContactView, and so on), and every word that view displays comes from the pages.* keys in src/i18n/<locale>.json. The default-locale route (/about) and the locale-prefixed route (/nl/about) render the same view — the only difference is which locale’s dictionary entries it reads:
---
// src/pages/[locale]/about.astro — generated for every non-default locale
import AboutView from '@/components/pages/views/AboutView.astro';
import { getSecondaryLocales } from '@/i18n';
export function getStaticPaths() {
return getSecondaryLocales().map((locale) => ({ params: { locale } }));
}
const { locale } = Astro.params;
---
<AboutView locale={locale} />
So localizing these pages is the same job as translating any other UI string — you edit JSON, not markup:
// src/i18n/nl.json
{
"pages": {
"about": {
"meta": { "title": "Over", "description": "…" },
"hero": { "badge": "Over", "titleLead": "Astro Rocket —", "description": "…" }
}
}
}
The moment nl is in your locales list, /nl, /nl/about, /nl/services, and /nl/contact are generated for you. Any pages.* key you haven’t translated yet falls back to the English copy, so the page is always complete and never blank.
Don’t create
src/pages/nl/about.astro. The locale route (src/pages/[locale]/about.astro) already generates that URL; a hand-written copy collides with it and fails the build. Translate thepages.about.*keys instead.
Your own pages — one file per locale
For pages you add yourself — anything beyond the four built-ins — you’re in plain Astro filesystem routing. A Dutch version of a custom /pricing page is a new file at src/pages/nl/pricing.astro:
---
import MarketingLayout from '@/layouts/MarketingLayout.astro';
---
<MarketingLayout
title="Prijzen"
description="Onze abonnementen en tarieven."
>
<section class="container py-24">
<h1 class="text-5xl font-bold">Prijzen</h1>
</section>
</MarketingLayout>
You don’t pass a locale prop — the layout and every component derive the active locale from the URL via getLocaleFromPath(). You also don’t have to translate every custom page on day one: a page that doesn’t exist in Dutch simply isn’t reachable at its Dutch URL. If a custom page has a lot of copy, you can follow the same View-plus-dictionary pattern the built-in pages use so its text lives in the dictionary too — but for most one-off pages, a translated file is the simplest path.
Step 3 — translate the built-in UI strings
The LanguageSwitcher, “Read more” links, “Published on” labels, pagination, share buttons, forms, the 404 page — every shared string the theme renders lives in src/i18n/<locale>.json. English and Dutch ship with the theme. Open them and you’ll see paired keys:
// src/i18n/en.json
{
"common": { "readMore": "Read more" },
"blog": { "readingTime": "{minutes} min read" }
}
// src/i18n/nl.json
{
"common": { "readMore": "Lees meer" },
"blog": { "readingTime": "{minutes} min leestijd" }
}
In any .astro file you resolve a string with the t() helper:
---
import { t, getLocaleFromPath } from '@/i18n';
const locale = getLocaleFromPath(Astro.url.pathname);
---
<a href="/blog">{t('common.readMore', locale)}</a>
Missing keys fall back to the default locale’s value, then to the key itself — so partial translations are visible but never break the page. The {minutes} placeholder is filled via the vars argument: t('blog.readingTime', locale, { minutes: 5 }) → "5 min leestijd".
Strings vs. structured copy: t() and tData()
t() returns a single string. For copy that’s naturally a list or an object — the feature cards on the About page, an FAQ, a set of steps — use tData(), which returns the raw (typed) JSON value instead of a string:
---
import { tData, getLocaleFromPath } from '@/i18n';
const locale = getLocaleFromPath(Astro.url.pathname);
const facts = tData<{ icon: string; title: string; description: string }[]>(
'pages.about.intro.facts',
locale,
);
---
{facts?.map((f) => <Card title={f.title}>{f.description}</Card>)}
Same key-based lookup and the same default-locale fallback as t() — it just hands you arrays and objects, so a whole section’s content can live in the dictionary alongside its strings. This is exactly how the built-in pages keep every word translatable.
This same dictionary drives the theme’s own chrome for you — the skip-to-content link, the back-to-top button, the header and mobile-menu navigation (labels and aria-labels), the colour-theme and light/dark pickers, the search dialog (including the “start typing” and “no results” messages its script shows at runtime), pagination labels, “Related posts”, the table-of-contents heading, the share buttons, and the contact and newsletter forms (including their client-side status messages), plus the 404 page. Translate the keys and the whole site speaks the new language; you don’t touch component code.
Adding a third language
Adding a language is two steps — no code edits:
- Create
src/i18n/<code>.json(say,de.json) mirroring the structure ofen.json. It’s discovered and loaded automatically — no import or registration insrc/i18n/index.ts. - Add the locale code to
localesini18n.config.ts(and give it alocaleNamesentry) so it gets served.
That’s the whole onboarding. The built-in pages appear in German immediately (falling back to English for any key you haven’t translated yet); then translate content into src/content/blog/de/ and src/content/projects/de/, and add src/pages/de/* for any custom pages you want.
Step 4 — localize the navigation, logo, and footer
You write each navigation entry once in src/config/nav.config.ts (navItems, footerNavItems, legalLinks); the Header and Footer localize it for the active locale automatically:
- Paths are locale-prefixed via
localizedPath—/blogstays/blogon the default locale and becomes/<locale>/blogelsewhere. External,mailto:/tel:, and#anchorhrefs are left untouched, and the logo points at the locale’s home (/or/<locale>). - Labels are translated when an item carries a
labelKeypointing at a string insrc/i18n/<locale>.json(the bundled items usenav.items.*). Without alabelKey, the literallabelis used as-is.
So for most sites, translating the nav.items.* keys is all the navigation needs. For the rare case where a locale needs a structurally different label or path — say a localized slug — add a per-locale locales override to the item:
{ label: 'About', href: '/about', order: 4, labelKey: 'nav.items.about',
locales: { nl: { href: '/over-ons' } } },
The footer copyright follows the same dictionary: it reads footer.copyright in every locale (edit that key — or pass a copyright prop — to change the line). With i18n off, none of this runs and the nav renders exactly as written.
How URLs work
With the default prefixDefaultLocale: false setting:
- The default locale lives at the root:
/,/about,/blog/hello-world. - Every other locale lives under its prefix:
/nl/,/nl/about,/nl/blog/hallo-wereld.
The LanguageSwitcher and the hreflang tags link to each translation’s real URL — so a visitor reading /blog/hello-world who clicks “Nederlands” lands on the actual Dutch post, even when that post is slugged differently. The theme works out which URL that is using one of two pairing strategies:
- Identical slug across locales —
/blog/hello-world↔/nl/blog/hello-world, paired automatically with zero config. - Locale-specific slugs — more natural for SEO (
/nl/blog/hallo-wereld). Give both posts the same canonicaluidin frontmatter (the same id<PostLink>uses) and the theme links them correctly across locales and advertises the right URL inhreflang.
Either way, a locale with no translation of the current post is dropped from hreflang, and the switcher sends the visitor to that locale’s blog index instead of a dead URL — so switching language never 404s. (The built-in and standalone pages resolve alternates by swapping the locale segment, which is correct when their paths match across locales.)
SEO checklist
When i18n is on, Astro Rocket’s SEO component automatically emits the right tags. A localized page’s <head> contains:
<link rel="alternate" hreflang="en" href="https://yoursite.com/about" />
<link rel="alternate" hreflang="nl" href="https://yoursite.com/nl/about" />
<link rel="alternate" hreflang="x-default" href="https://yoursite.com/about" />
That tells Google which version corresponds to which locale, and which to show when no language matches. Two things to keep right:
- Reciprocity. A page reachable in one locale should be reachable in the others for the
hreflangloop to hold. Your built-in pages, blog, and projects handle this for you; for custom pages, missing translations simply don’t get an alternate (which is fine — Google just won’t see a translation that doesn’t exist). - Your sitemap reflects the locale URLs. The
@astrojs/sitemapintegration handles this automatically when i18n is configured.
Run a Lighthouse SEO audit after deploying — with i18n correctly configured you should score 100/100 on the SEO section without extra work.
Switching the default language
defaultLocale is a routing label, not a content switch. Set it to 'zh-CN' and that locale serves at the site root (/blog) while English moves under /en/. One caveat: changing the label doesn’t move your files. To make a different language the default, also rename the matching content folder so the root URL resolves to the right content:
src/content/blog/en/ → src/content/blog/zh-CN/
The locale code in i18n.config.ts and the folder name under src/content/blog/ (and src/content/projects/) must match — the folder name is what the route resolves to. The bundled posts and projects carry an explicit locale field for exactly this reason, and the build validates the pairing, so a non-English default works end to end: the built-in pages, blog index, posts, pagination, tag archives, RSS feed, and dynamic OG images all follow the configured default rather than assuming English.
Turning it back off
If you experiment with i18n and decide not to ship it, the rollback is one line. In src/config/i18n.config.ts:
enabled: false,
locales: ['en'],
Deploy. The LanguageSwitcher disappears, hreflang tags stop emitting, the /<locale>/ routes vanish, and the site returns to single-locale behavior. Your src/pages/nl/, src/content/blog/nl/, src/content/projects/nl/, and src/i18n/nl.json files stay in the repo — dormant but preserved. Flip the flag back when you’re ready.
What’s next
The system covers the full surface of a marketing-plus-blog site today. A few things remain on the roadmap:
- Optional browser-locale detection. The
detectBrowserLocaleflag is wired into the config; redirect-on-first-visit logic can be added without changing the API. - Per-page locale-slug mapping for custom pages that want fully localized URLs in addition to translated content.
- A pure-CSS
LanguageSwitcherusing<details>, removing the small inline JS the dropdown panel needs today.
This feature exists, and has matured, because the community asked. It started with #207; the auto-loaded dictionaries, locale-validated schemas, non-English default locale, dictionary-driven pages, automatic blog and projects routing, and fully localized chrome all came from real-world reports and requests (#414, #415, #418, #419, #422, #437, #438). Thanks to everyone who filed them. If you build something with the i18n system and hit a rough edge, open an issue — the next iteration will come the same way.
For now: flip the flag, add your content in a second language, translate the dictionary, and ship it. The complete reference is in the Internationalization (i18n) section of the README — keep it open while you set up your second locale.