Astro + Locize
Astro 6 ships built-in i18n routing — Locize complements it with cloud translation management and build-time JSON sync via locize-cli.
Astro is the static-first web framework for content-driven sites — file-based routing, zero JavaScript by default, "islands" for opt-in interactivity, and an SSR-on-demand mode for the parts that need it. Since v4.0, 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. No runtime dependencies, no peer-dep chain, no SSR/hydration considerations.
- Framework: Astro (built-in i18n routing since v4.0)
- Current version: v6.x (Node 22+)
- Format: i18next JSON v4 (the default Locize format)
- Loading strategy: bundle-at-build-time via locize-cli (static-friendly)
- Translation function: ~5-line custom helper (Astro doesn't ship one)
- Example repo: locize-astro-example
- Best for: content-heavy static sites; mount a React/Vue island when you need runtime saveMissing or in-context editing
Configure Astro's built-in i18n
Astro owns URL routing for free — just declare your locales in astro.config.mjs:
// astro.config.mjs
import { defineConfig } from 'astro/config'
export default defineConfig({
i18n: {
defaultLocale: 'en',
locales: ['en', 'de'],
routing: {
prefixDefaultLocale: true // /en/, /de/ — uniform URLs
}
}
})With prefixDefaultLocale: true, every locale gets a URL prefix (/en/, /de/) — uniform, SEO-clean, and easy to handle with dynamic [lang]/ routes + getStaticPaths.
Sync translations with locize-cli
Translations live in your Locize project. locize-cli pulls them into src/i18n/locales/{lang}/ before each build:
// package.json
{
"scripts": {
"downloadLocales": "locize download --project-id=$LOCIZE_PROJECT_ID --ver=latest --cdn-type=standard --clean=true --path=./src/i18n/locales",
"syncLocales": "locize sync --project-id=$LOCIZE_PROJECT_ID --ver=latest --cdn-type=standard --api-key=$LOCIZE_API_KEY --path=./src/i18n/locales --dry=true"
}
}Run npm run downloadLocales as a prebuild hook (or in CI before astro build) so the bundled JSON is always fresh. Use syncLocales (drop --dry=true) to push locally-added keys back up.
The translation tree + t() helper
Two small files: ui.ts assembles the namespaced JSON into a flat, type-safe lookup tree; utils.ts exposes getLangFromUrl and useTranslations.
1. The message tree
// src/i18n/ui.ts
import enCommon from './locales/en/common.json' with { type: 'json' }
import enIndex from './locales/en/index.json' with { type: 'json' }
import deCommon from './locales/de/common.json' with { type: 'json' }
import deIndex from './locales/de/index.json' with { type: 'json' }
type Messages = Record<string, string>
const prefix = (ns: string, o: Messages): Messages =>
Object.fromEntries(Object.entries(o).map(([k, v]) => [`${ns}.${k}`, v]))
export const ui = {
en: { ...prefix('common', enCommon), ...prefix('index', enIndex) },
de: { ...prefix('common', deCommon), ...prefix('index', deIndex) }
} as const
export const defaultLang = 'en'
export type Lang = keyof typeof ui
export type TranslationKey = keyof typeof ui[typeof defaultLang]2. The helpers
// 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 = ui[lang][key] ?? ui[defaultLang][key] ?? key
if (!values) return raw
return raw.replace(/\{(\w+)\}/g, (_, k) =>
values[k] !== undefined ? String(values[k]) : `{${k}}`
)
}
}Five lines of logic in useTranslations — fallback to default locale, fallback to key if both miss, simple {name} interpolation. The TranslationKey type is derived from the default-locale tree, so TypeScript autocompletes keys in .astro files. The full working example (with language picker, layout, and per-locale dynamic routes) lives at locize-astro-example.
What this shape doesn't include
Astro is static-by-default, so the integration is intentionally smaller than the Nuxt or React Router v7 ones:
- No runtime saveMissing — Astro pages are pre-rendered; a runtime push from production users isn't possible without an SSR adapter, and even then Astro discourages writes from the static layer. Land new keys via
npm run syncLocales, i18next-cli static extraction, or the Locize web app. - No in-context editor on static pages — the locize editor needs a re-rendering DOM that static output doesn't provide.
- No live CDN fetch — translations are bundled at build time. To serve a fresh translation, run
npm run downloadLocalesand redeploy.
All three of these do work inside framework islands: mount a React, Vue, Svelte, Solid, or Preact island via the matching @astrojs/<framework> integration, then use i18next-locize-backend + the locize package inside that island. The React Router v7 walkthrough demonstrates the same live-fetch + in-context shape per-island.
Frequently asked questions
Astro added built-in i18n routing in v4.0 (late 2023, stable in v5+). It owns URL prefixing (/en/, /de/), the Astro.currentLocale helper, browser-language detection (Astro.preferredLocale / Astro.preferredLocaleList), and automatic <link rel="alternate" hreflang> SEO tags. What it does NOT provide is a translation function (t()), pluralization, or message interpolation — Astro's official i18n recipe shows the build-it-yourself pattern with per-locale JSON imports and a five-line t() helper.
Yes — and because Astro's built-in i18n is routing-only, the Locize integration is the simplest of any major framework. 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 them into a flat lookup tree, and Astro's static build picks them up at compile time. No runtime dependencies, no peer-dep chain, no SSR/hydration considerations. The full example lives at github.com/locize/locize-astro-example.
Three steps: (1) configure Astro's built-in i18n in astro.config.mjs with your locales array and prefixDefaultLocale: true, (2) wire locize-cli download/sync scripts in package.json pointing at ./src/i18n/locales, (3) write a small useTranslations(lang) helper in src/i18n/utils.ts that returns a t() function with optional {name}-style interpolation. Astro's official i18n recipe demonstrates the exact pattern Locize plugs into. A complete working example is at github.com/locize/locize-astro-example.
Astro is static-by-default — pages are pre-rendered to HTML at build time and served as static files (or via an SSR adapter). Live-fetching translations from a CDN per request would defeat the static model and add latency on every page view. Build-time sync via locize-cli keeps the deployment static, edge-cacheable, and serverless-friendly. The trade-off is that translation updates require re-running locize download + redeploying. For live updates without a rebuild, mount any React/Vue/Svelte island via @astrojs/<framework> and use i18next-locize-backend inside it.
Not from the static layer — Astro pages are pre-rendered, so a runtime push from production users isn't possible without an SSR adapter, and even then Astro discourages writes from the static surface. saveMissing + the Locize in-context editor (?incontext=true) DO work inside framework islands: mount a React, Vue, Svelte, Solid, or Preact island via the matching @astrojs/<framework> integration, then use i18next-locize-backend + the locize package inside that island. The React Router v7 example demonstrates the live-fetch + in-context shape; Astro can adopt the same pattern per-island.
Three paths: (1) `npm run syncLocales` — locize-cli reads your local JSON and uploads any keys not yet in Locize. Manual or CI-triggered, no write-key in the browser. (2) Static extraction via i18next-cli scans your .astro source for t() calls, writes new keys into the local JSON, then sync up as above. (3) Add keys directly in the Locize web app and pull them down with `downloadLocales` before the next build. Pick whichever matches your workflow — Astro's static model means there's no runtime saveMissing path to worry about.
All three share the same bundle-at-build-time-from-Locize shape, but Astro is the simplest because there's no UI framework runtime to wire. Nuxt brings @nuxtjs/i18n + vue-i18n (locize 4.1's vue-i18n implementation, hydration handling, in-context editor wiring). React Router v7 brings i18next + remix-i18next + i18next-locize-backend with live CDN fetch and in-context. Astro's integration is closer to a single afternoon's work — pick Astro when your site is content-heavy and you don't need a runtime framework; pick the others when you need richer interactivity.