How to internationalize an Astro site with Locize
Astro is the static-first web framework for content-driven sites — file-based routing, zero JavaScript by default, "islands" for opt-in interactivity, an SSR-on-demand mode for the parts that need it. Since v4.0 (late 2023, stable in v5+, current in v6.x), Astro ships built-in i18n routing: URL prefixing (/en/, /de/), the Astro.currentLocale helper, browser-language detection, and automatic <link rel="alternate" hreflang> SEO tags.
What Astro's built-in i18n does not provide is a translation function (t()), pluralization, or message interpolation. Astro's official i18n recipe shows the canonical "build it yourself" pattern: import a per-locale JSON file, write five lines of helper, done. That's exactly where Locize plugs in — locize-cli downloads the latest published JSON from Locize into src/i18n/locales/{lng}/{ns}.json at build time, a tiny ui.ts module assembles those into a flat lookup tree, and Astro's static build picks them up at compile time.
This post walks the integration end-to-end. The complete code lives at github.com/locize/locize-astro-example.
If you're on Nuxt instead, follow the Nuxt 4 walkthrough. If you're on React Router v7 (framework mode), see the React Router v7 walkthrough — both cover richer integrations (in-context editor, runtime saveMissing) that don't fit Astro's static-by-default model directly.
TL;DR
- Configure Astro's built-in i18n routing in
astro.config.mjs— locale list +prefixDefaultLocale: truefor uniform URLs. - Use dynamic
[lang]/page routes withgetStaticPathsso every locale is statically pre-rendered. - Sync translations into the app at build time via
locize-cli'sdownloadcommand. No runtime CDN hop, no SSR considerations. - Write a five-line
useTranslations(lang)helper insrc/i18n/utils.ts. Astro deliberately doesn't ship at(); this is the canonical pattern from their own docs. - Land new keys via
npm run syncLocales, i18next-cli static extraction, or the Locize web app — whichever fits your workflow. No runtimesaveMissingis possible from the static layer. - Need runtime saveMissing, live CDN fetch, or the in-context editor? Mount any React/Vue/Svelte island via
@astrojs/<framework>and usei18next-locize-backend+ thelocizepackage inside the island. - Full working example: github.com/locize/locize-astro-example
How the pieces fit
Astro is static-by-default. Pages are pre-rendered to HTML at build time and served as static files (or, in SSR mode, via an adapter on demand). Astro's built-in i18n owns the routing layer — URL prefixing, locale-aware redirects, hreflang tags, browser detection helpers — but it deliberately stays out of the message-translation layer. That's why every Astro i18n tutorial ends with "now write your own t() helper": Astro's philosophy is to give you primitives, not opinions about translation.
Locize is the translation management layer. The integration is bundle-only at runtime:
- Build time:
locize-clidownloads the latest published JSON from the Locize CDN intosrc/i18n/locales/{lng}/{ns}.json. The committed JSON files are imported directly bysrc/i18n/ui.tsat module-load time and assembled into a flat lookup tree. t()is local TypeScript — five lines that handle locale fallback +{name}-style interpolation. No runtime library, no peer dep.- Updates require a rebuild — same as any other Astro content change. Run
npm run downloadLocalesand redeploy.
That last point is the trade-off. A "translation published in Locize → end users see it" round trip means re-running the build. If you'd rather have live CDN-backed fetch (publish → next page view picks it up, no redeploy), you mount a framework island and use i18next-locize-backend inside it — the React Router v7 walkthrough covers that shape, and you can adopt the same pattern per-island in Astro. We'll come back to this at the end.
Let's get into it
Prerequisites
Node.js 22+ (Astro 6 requires it), npm/pnpm/yarn, and basic familiarity with Astro. The example below targets Astro 6.3.
Project layout
Scaffold a fresh Astro app, or git clone the example:
npm create astro@latest my-app
cd my-appThe relevant directory shape after we wire i18n + Locize:
my-app/
├── astro.config.mjs — Astro's built-in i18n config
├── package.json — locize-cli download/sync scripts
└── src/
├── pages/
│ ├── index.astro — root /, redirects to /en/
│ └── [lang]/
│ ├── index.astro — home, renders for each locale via getStaticPaths
│ └── second.astro — secondary page, same pattern
├── layouts/
│ └── Layout.astro — shared <html>/<head>/<body>
├── components/
│ └── LanguagePicker.astro — swaps the /{lang}/ prefix
└── i18n/
├── ui.ts — assembles namespaced JSON into a flat tree
├── utils.ts — getLangFromUrl + useTranslations helpers
└── locales/ — locize-cli download target (one JSON per ns)
├── en/
│ ├── common.json
│ ├── index.json
│ └── second.json
└── de/
├── common.json
├── index.json
└── second.jsonInstall dependencies
npm install astro
npm install --save-dev locize-cliThat's it. No translation runtime library, no framework integration package. Astro's built-in i18n + locize-cli + ~50 lines of helpers cover the whole story.
Configure Astro's built-in i18n in astro.config.mjs
import { defineConfig } from 'astro/config'
export default defineConfig({
i18n: {
defaultLocale: 'en',
locales: ['en', 'de'],
routing: {
prefixDefaultLocale: true // /en/, /de/ — uniform URLs
}
}
})Astro now owns:
- URL routing for every page under
src/pages/[lang]/—/en/,/de/,/en/second,/de/second, etc. Astro.currentLocaleavailable in every page/component for free.- Browser-language detection via
Astro.preferredLocaleandAstro.preferredLocaleList(SSR/on-demand routes only). - Automatic
<link rel="alternate" hreflang>tags when you opt in.
With prefixDefaultLocale: true, every locale gets a URL prefix — uniform, SEO-clean, and easy to handle with dynamic [lang]/ routes + getStaticPaths. (If you'd rather have the default locale served at / without a prefix, set it to false and use file-based pages instead of [lang]/. The example uses the prefixed shape because it scales better.)
Download translations from Locize at build time
Add to package.json:
{
"scripts": {
"downloadLocales": "locize download --project-id=<your-id> --ver=latest --cdn-type=standard --clean=true --path=./src/i18n/locales",
"syncLocales": "locize sync --project-id=<your-id> --ver=latest --cdn-type=standard --api-key=<your-write-key> --path=./src/i18n/locales --dry=true"
}
}Run npm run downloadLocales before npm run build (manually, in CI, or as a prebuild hook) so the bundled JSON is always fresh.
Standard vs Pro CDN. Locize ships two CDN infrastructures (full comparison at CDN types: Standard vs. Pro): Standard at
api.lite.locize.appis BunnyCDN-backed, free for generous monthly download volumes, public-only (default for new projects); Pro atapi.locize.appis CloudFront-backed, paid, supports private downloads + custom cache control. Match--cdn-typeon the cli flag to whatever your project is on.
The message tree — src/i18n/ui.ts
Locize emits one JSON per namespace per language. Astro doesn't have a notion of i18n namespaces, so we assemble the on-disk layout into a single flat lookup tree at module-load time, with the namespace baked into each key as a prefix:
import enCommon from './locales/en/common.json' with { type: 'json' }
import enIndex from './locales/en/index.json' with { type: 'json' }
import enSecond from './locales/en/second.json' with { type: 'json' }
import deCommon from './locales/de/common.json' with { type: 'json' }
import deIndex from './locales/de/index.json' with { type: 'json' }
import deSecond from './locales/de/second.json' with { type: 'json' }
type Messages = Record<string, string>
function prefix (ns: string, obj: Messages): Messages {
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => [`${ns}.${k}`, v])
)
}
export const ui = {
en: { ...prefix('common', enCommon), ...prefix('index', enIndex), ...prefix('second', enSecond) },
de: { ...prefix('common', deCommon), ...prefix('index', deIndex), ...prefix('second', deSecond) }
} as const
export const defaultLang = 'en'
export const languages = {
en: 'English',
de: 'Deutsch'
} as const
export type Lang = keyof typeof ui
export type TranslationKey = keyof typeof ui[typeof defaultLang]The output looks like:
ui.en = {
'common.headTitle': 'Locize + Astro',
'common.skipToContent': 'Skip to content',
'index.title': 'Hello, {name}!',
'second.title': 'The second page',
// ...
}TranslationKey is derived from the default-locale tree, so TypeScript autocompletes the key in .astro files and rejects typos at compile time.
The t() helper — src/i18n/utils.ts
import { ui, defaultLang, type Lang, type TranslationKey } from './ui'
export function getLangFromUrl (url: URL): Lang {
const [, lang] = url.pathname.split('/')
if (lang && lang in ui) return lang as Lang
return defaultLang
}
export function useTranslations (lang: Lang) {
return function t (
key: TranslationKey,
values?: Record<string, string | number>
): string {
const raw: string = ui[lang][key] ?? ui[defaultLang][key] ?? key
if (!values) return raw
return raw.replace(/\{(\w+)\}/g, (_, k: string) =>
values[k] !== undefined ? String(values[k]) : `{${k}}`
)
}
}Five lines of logic. Fallback chain: requested key in the active locale, then the default locale, then the raw key string. Optional {name}-style interpolation passes values via the second argument. That's the whole runtime API.
This pattern is the same one Astro's official i18n recipe shows — we've just layered Locize's namespaced on-disk layout on top.
Using t() in pages
Dynamic [lang]/ routes + getStaticPaths mean Astro pre-renders one HTML file per locale at build time:
---
// src/pages/[lang]/index.astro
import Layout from '../../layouts/Layout.astro'
import { ui, type Lang } from '../../i18n/ui'
import { useTranslations } from '../../i18n/utils'
import { getRelativeLocaleUrl } from 'astro:i18n'
export function getStaticPaths () {
return Object.keys(ui).map(lang => ({ params: { lang } }))
}
const { lang } = Astro.params as { lang: Lang }
const t = useTranslations(lang)
---
<Layout lang={lang} title={t('common.headTitle')}>
<h1>{t('index.title', { name: 'Astro' })}</h1>
<p>{t('index.subtitle')}</p>
<p>
<a href={getRelativeLocaleUrl(lang, 'second/')}>
{t('index.goto.second')}
</a>
</p>
</Layout>The getStaticPaths function maps each locale to a static path; lang arrives as a route param. Pure compile-time work — no server-side runtime, no hydration considerations.
The language picker
Astro's docs i18n recipe shows a simple picker that swaps the locale prefix on the current URL:
---
// src/components/LanguagePicker.astro
import { languages, type Lang } from '../i18n/ui'
interface Props {
currentLang: Lang
}
const { currentLang } = Astro.props
function pathForLang (target: Lang): string {
const { pathname } = Astro.url
const parts = pathname.split('/')
// "/en/foo/bar" → ["", "en", "foo", "bar"]
parts[1] = target
return parts.join('/')
}
---
<nav>
<ul>
{Object.entries(languages).map(([lang, label]) => (
<li>
{lang === currentLang ? (
<strong>{label}</strong>
) : (
<a href={pathForLang(lang as Lang)}>{label}</a>
)}
</li>
))}
</ul>
</nav>Drop it into src/layouts/Layout.astro and it's available on every page.
Three ways to land new keys in Locize
Astro is static-by-default — pages are pre-rendered, so a runtime saveMissing push from production users isn't possible without an SSR adapter, and Astro discourages writes from the static layer anyway. There are three workable paths instead:
npm run syncLocales(wired inpackage.json, drop the--dry=trueflag to actually push).locize-clireads your localsrc/i18n/locales/{lng}/{ns}.jsonand uploads any keys not yet in Locize. Manual or CI-triggered, no write-key in the browser.- Static extraction via
i18next-clior similar — scans your.astrosource fort('…')calls, writes new keys into the local JSON, then sync up as above. - The Locize web app — add keys directly in the editor and pull them down with
downloadLocalesbefore the next build.
Pick whichever fits your workflow. None of them require shipping a write API key in your build artifact.
What this shape intentionally doesn't include
Three things you might expect from the Nuxt or React Router v7 walkthroughs are deliberately absent here:
- No runtime
saveMissing. See above — Astro static builds can't push, and an SSR Astro app would do this from inside an island, not from the static layer. - No in-context editor on static pages. The
locizeeditor needs a DOM that re-renders when the editor updates a string. Astro's static output doesn't have that hook. - No live CDN fetch. Translations are bundled at build time. To serve a fresh translation, run
npm run downloadLocalesand redeploy. This matches Astro's static-first model and keeps the build artifact self-contained.
All three of these do work inside framework islands. Mount a React, Vue, Svelte, Solid, or Preact island via the matching @astrojs/<framework> integration, and inside that island use i18next-locize-backend + the locize package directly. The Astro static layer handles the routing + the page chrome; the islands handle the parts that need a runtime.
When to add a framework island
The static-only integration covers most content sites — blogs, marketing pages, docs, landing pages. Add a framework island when you need:
- Live translation updates without a redeploy. Use
i18next-locize-backendinside the island to fetch translations from the Locize CDN on each visit (or per-route, if you cache). - In-context editing for translators. The
locizepackage's?incontext=trueoverlay needs a runtime that re-renders on string changes — only available inside a hydrated island. - Runtime
saveMissing. Same constraint — a hydrated island can push new keys; static HTML can't.
The pattern looks like this (for a React island):
---
// src/pages/[lang]/interactive.astro
import Layout from '../../layouts/Layout.astro'
import I18nIsland from '../../components/I18nIsland.tsx'
---
<Layout lang={Astro.currentLocale}>
<I18nIsland client:load lang={Astro.currentLocale} />
</Layout>// src/components/I18nIsland.tsx
import i18next from 'i18next'
import Backend from 'i18next-locize-backend'
import { initReactI18next, useTranslation } from 'react-i18next'
i18next
.use(Backend)
.use(initReactI18next)
.init({
fallbackLng: 'en',
saveMissing: import.meta.env.DEV,
backend: {
projectId: import.meta.env.PUBLIC_LOCIZE_PROJECT_ID,
apiKey: import.meta.env.DEV ? import.meta.env.LOCIZE_API_KEY : undefined,
version: 'latest'
}
})
export default function I18nIsland ({ lang }: { lang: string }) {
const { t, i18n } = useTranslation()
if (i18n.language !== lang) i18n.changeLanguage(lang)
return <h2>{t('island.heading')}</h2>
}client:load tells Astro to hydrate this component immediately on page load; you can use client:visible to defer hydration until the island enters the viewport. The exact configuration of i18next-locize-backend is documented in the React Router v7 walkthrough — the same backend wiring works in any React/Vue/Svelte/Solid context, including inside an Astro island.
🎉 Congratulations
A working Astro 6 + Locize setup gives you:
- Static-first i18n with no flash of untranslated content (everything is pre-rendered per locale)
- SEO-clean URL prefixing via Astro's built-in i18n routing
- Build-time translation sync via
locize-cli— serverless-friendly, no per-request CDN hop - Type-safe
t()with autocomplete on translation keys - An escape hatch to dynamic islands when you need runtime updates or in-context editing
🧑💻 The complete code: github.com/locize/locize-astro-example.
The founders of Locize are also the creators of i18next — by using Locize you directly support the future of i18next.
See also
- How to internationalize a Nuxt 4 app with @nuxtjs/i18n and Locize — Vue-side counterpart with richer integration (vue-i18n, in-context editor, runtime saveMissing)
- How to internationalize a React Router v7 app with remix-i18next — React-side counterpart with live CDN fetch via
i18next-locize-backend - Astro's built-in i18n routing — the routing primitive this walkthrough builds on
- Astro i18n recipe — the build-it-yourself pattern Locize plugs into
locize-cli— the build-time sync tool