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. Since launch the system has grown into a complete multilingual layer that localizes not just your pages but the whole blog, the projects section, the navigation, the logo, and every built-in UI string, 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 considerably since it first shipped, much of it from community feedback. If you followed an earlier version of this guide, note one thing that changed: extra language dictionaries are now auto-loaded — you no longer edit
src/i18n/index.tsto register them.
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) — the translated UI strings, read through at()helper. - Locale-aware content collections — your blog, projects, and pages carry a
localefield and live in per-locale folders. - Automatic localized routes — once content exists in a locale, the theme generates that locale’s blog and projects sections 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
Six things start working 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” page 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. - 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 — the navigation, logo, footer, forms, 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 now appears in your header. Clicking “Nederlands” still lands you on English pages until Dutch content exists — that’s the next step.
Step 2 — translate your content
Astro Rocket splits content into two camps, and they’re handled differently:
- Collection content (blog posts and projects) — you only add files; the routing is automatic.
- Standalone pages (Home, About, Contact, Services) — these are plain 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.
Upgrading? If you previously hand-built
src/pages/<locale>/blog*wrapper files to work around the old behavior, delete them — they now collide with the generated routes.
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.)
Standalone pages — one file per locale
Astro is filesystem-routed, so a Dutch “About” page is just a new file at src/pages/nl/about.astro:
| URL | Source file |
|---|---|
/about | src/pages/about.astro |
/contact | src/pages/contact.astro |
/nl/ | src/pages/nl/index.astro (create) |
/nl/about | src/pages/nl/about.astro (create) |
/nl/contact | src/pages/nl/contact.astro (create) |
The simplest Dutch page is a copy of the English source with the visible text translated. A src/pages/nl/index.astro can be as minimal as:
---
import MarketingLayout from '@/layouts/MarketingLayout.astro';
---
<MarketingLayout
title="Welkom bij Astro Rocket"
description="Een productieklaar Astro 6 starter-thema."
>
<section class="container py-24">
<h1 class="text-5xl font-bold">Hallo, wereld.</h1>
<p class="mt-4 text-lg text-foreground-muted">
Dit is de Nederlandse versie van de homepagina.
</p>
</section>
</MarketingLayout>
You don’t need to 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 standalone page on day one: a page that doesn’t exist in Dutch simply isn’t reachable at its Dutch URL. For a soft launch, start with the homepage, About, and Contact; add the rest as you write them. (Your blog and projects, by contrast, light up automatically the moment their content exists.)
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".
This same dictionary drives the theme’s own chrome for you — the skip-to-content link, the back-to-top button, pagination labels, “Related posts”, the share buttons, the contact and newsletter forms (including their client-side status messages), and the 404 page all read from it. 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 now — 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. Then translate content into src/content/blog/de/, src/content/projects/de/, and add src/pages/de/* for the standalone 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' } } },
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. (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 auto-localized blog and projects handle this for you; for standalone 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. With that done, a non-English default works end to end: the 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 standalone 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, automatic blog and projects routing, and localized navigation 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.