Skip to content
May 21, 20268 min readTutorials

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

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: true for uniform URLs.
  • Use dynamic [lang]/ page routes with getStaticPaths so every locale is statically pre-rendered.
  • Sync translations into the app at build time via locize-cli's download command. No runtime CDN hop, no SSR considerations.
  • Write a five-line useTranslations(lang) helper in src/i18n/utils.ts. Astro deliberately doesn't ship a t(); 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 runtime saveMissing is 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 use i18next-locize-backend + the locize package 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-cli downloads the latest published JSON from the Locize CDN into src/i18n/locales/{lng}/{ns}.json. The committed JSON files are imported directly by src/i18n/ui.ts at 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 downloadLocales and 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-app

The relevant directory shape after we wire i18n + Locize:

my-app/
├── astro.config.mjsAstro'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.json

Install dependencies

npm install astro
npm install --save-dev locize-cli

That'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.currentLocale available in every page/component for free.
  • Browser-language detection via Astro.preferredLocale and Astro.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.app is BunnyCDN-backed, free for generous monthly download volumes, public-only (default for new projects); Pro at api.locize.app is CloudFront-backed, paid, supports private downloads + custom cache control. Match --cdn-type on 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:

  1. npm run syncLocales (wired in package.json, drop the --dry=true flag to actually push). locize-cli reads your local src/i18n/locales/{lng}/{ns}.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 or similar — scans your .astro source for t('…') calls, writes new keys into the local JSON, then sync up as above.
  3. The Locize web app — add keys directly in the editor and pull them down with downloadLocales before 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 locize editor 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 downloadLocales and 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-backend inside the island to fetch translations from the Locize CDN on each visit (or per-route, if you cache).
  • In-context editing for translators. The locize package's ?incontext=true overlay 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