Skip to content

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 inlocize-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.

Key facts
  • 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 downloadLocales and 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

What is Astro's built-in i18n?

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.

Does Locize support Astro?

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.

How do I connect Astro to Locize?

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.

Why bundle translations at build time instead of fetching from the Locize CDN at runtime?

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.

Can I use saveMissing or the in-context editor with Astro?

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.

How do new keys land in my Locize project?

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.

How does Astro + Locize compare to Nuxt + Locize or React Router v7 + Locize?

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.