Get Started with astro-intl
A type-safe internationalization library for Astro, inspired by next-intl. No props drilling, dynamic loading, and full TypeScript support.
Installation
Install astro-intl using your preferred package manager.
npm install astro-intl pnpm add astro-intl yarn add astro-intl Optionally register the integration in your astro.config.mjs:
import { defineConfig } from 'astro/config';
import astroIntl from 'astro-intl';
export default defineConfig({
integrations: [astroIntl()],
}); File Structure
astro-intl expects a specific folder structure inside your project. Here is the recommended layout:
src/
├── i18n/
│ ├── messages/
│ │ ├── en.json ← English translations
│ │ └── es.json ← Spanish translations
│ └── request.ts ← Locale resolver
├── layouts/
│ └── Layout.astro ← Shared layout
├── pages/
│ └── [lang]/ ← Dynamic locale segment
│ ├── index.astro
│ └── docs.astro
└── components/
└── MyComponent.astro [lang] dynamic segment so Astro can generate one route per locale. src/i18n/messages/. The filename must match the locale code exactly (e.g. en.json, es.json). request.ts file exports a function that receives the locale string and returns the messages for that locale. Configuration
1. Create message files
Create one JSON file per locale inside src/i18n/messages/. You can organize keys into namespaces (nested objects).
{
"nav": {
"home": "Home",
"docs": "Documentation"
},
"hero": {
"title": "Hello, World!",
"description": "Welcome to <bold>astro-intl</bold>"
}
}2. Choose your approach
astro-intl offers two ways to load messages. Pick the one that fits your project best.
createIntlMiddleware, you must pass defaultLocale and locales directly in astro.config.mjs so that functions like getLocales(), getDefaultLocale(), and isValidLocale() work correctly. Option A — Messages in config (recommended)
Pass your message imports along with defaultLocale and locales directly in astro.config.mjs. No extra files needed.
import { defineConfig } from "astro/config";
import astroIntl from "astro-intl";
export default defineConfig({
integrations: [
astroIntl({
defaultLocale: "en",
locales: ["en", "es"],
messages: {
en: () => import("./src/i18n/messages/en.json"),
es: () => import("./src/i18n/messages/es.json"),
},
}),
],
});Option B — Custom request.ts (advanced)
For full control, use defineRequestConfig to create a request file — similar to next-intl. This lets you add custom logic like fetching messages from a CMS. You still need to pass defaultLocale and locales in astro.config.mjs.
import { defineRequestConfig } from "astro-intl";
export default defineRequestConfig(async (locale) => {
return {
locale,
messages: (await import(`./messages/${locale}.json`)).default,
};
});astro.config.mjs with locales:
import { defineConfig } from "astro/config";
import astroIntl from "astro-intl";
export default defineConfig({
integrations: [
astroIntl({
defaultLocale: "en",
locales: ["en", "es"],
}),
],
});3. Set up your Layout
Best practice: Call setRequestLocale once in a shared layout (e.g., src/layouts/Layout.astro) instead of repeating it on every page. This initializes the global i18n context before any component reads translations.
With Option A (messages in config):
---
import { setRequestLocale } from "astro-intl";
// No need to import anything — messages come from astro.config
await setRequestLocale(Astro.url);
---
<!DOCTYPE html>
<html>
<head>
<title>My Site</title>
</head>
<body>
<slot />
</body>
</html>With Option B (defineRequestConfig):
---
import { setRequestLocale } from "astro-intl";
import "../i18n/request"; // registers defineRequestConfig
await setRequestLocale(Astro.url);
---
<!DOCTYPE html>
<html>
<head>
<title>My Site</title>
</head>
<body>
<slot />
</body>
</html>3b. Middleware approach (alternative)
Instead of calling setRequestLocale in every layout, you can use createIntlMiddleware to handle it automatically for all routes. This also sets locales and defaultLocale in the store, so you don't need to pass them in astro.config.mjs.
Create a routing config:
export const routing = {
locales: ["en", "es"],
defaultLocale: "en",
} as const;Create the middleware:
import "@/i18n/request";
import { createIntlMiddleware } from "astro-intl/middleware";
import { routing } from "@/i18n/routing";
export const onRequest = createIntlMiddleware(routing);Your astro.config.mjs stays simple:
import { defineConfig } from "astro/config";
import astroIntl from "astro-intl";
export default defineConfig({
integrations: [astroIntl()],
});4. Export getStaticPaths
In every [lang] page, export getStaticPaths returning all supported locales so Astro can pre-render them.
export function getStaticPaths() {
return [
{ params: { lang: "en" } },
{ params: { lang: "es" } },
];
}Usage
Basic Translation
Once your layout is configured, simply use getTranslations(namespace) in any page to get a typed translation function for a specific namespace.
---
import { getTranslations } from "astro-intl";
// No need to call setRequestLocale here - it's already in the layout!
const tHero = getTranslations("hero");
const tNav = getTranslations("nav");
---
<h1>{tHero("title")}</h1>
<p>{tHero("description")}</p>
<a href="/">{tNav("home")}</a>Interpolation
Use {curlyOpen}varName{curlyClose} placeholders in your translation strings and pass a values object as the second argument to t(). Accepted types: string, number, boolean. Missing or null/undefined values keep the placeholder unchanged.
{
"hero": {
"greeting": "Hello, {name}!",
"info": "You have {count} new messages"
}
}---
const tHero = getTranslations("hero");
---
<p>{tHero("greeting", { name: "John" })}</p>
<!-- "Hello, John!" -->
<p>{tHero("info", { count: 5 })}</p>
<!-- "You have 5 new messages" -->
<p>{tHero("greeting")}</p>
<!-- "Hello, {name}!" — placeholder kept when no values -->Markup Translation
Use t.markup(key, tags) to interpolate HTML tags inside a translation string. The tags object maps tag names to functions that receive the inner content.
{
"hero": {
"description": "A <bold>type-safe</bold> solution for <link>Astro</link>"
}
}---
const tHero = getTranslations("hero");
---
<p set:html={tHero.markup("description", {
bold: (chunks) => `<strong>${chunks}</strong>`,
link: (chunks) => `<a href="https://astro.build">${chunks}</a>`,
})} />Markup + Interpolation
Combine variable interpolation with tag replacement by passing {curlyOpen} values, tags {curlyClose} to t.markup(). Interpolation runs first, then tags are processed.
{
"hero": {
"welcome": "Hello {name}, read our <link>docs</link> to get started"
}
}---
const tHero = getTranslations("hero");
---
<p set:html={tHero.markup("welcome", {
values: { name: "John" },
tags: {
link: (chunks) => `<a href="/docs">${chunks}</a>`,
}
})} />
<!-- "Hello John, read our <a href="/docs">docs</a> to get started" -->React Components
Use getTranslationsReact(namespace) inside React components, then call t.rich(key, tags) to interpolate React nodes.
import { getTranslationsReact } from "astro-intl";
export default function MyComponent() {
const t = getTranslationsReact("hero");
return (
<p>
{t.rich("description", {
bold: (chunks) => <strong>{chunks}</strong>,
link: (chunks) => <a href="https://astro.build">{chunks}</a>,
})}
</p>
);
}API Reference
setRequestLocale
setRequestLocale(url: URL, getConfig: Function): Promise<void>
Extracts the locale from the URL pathname (first segment) and calls your getConfig function to load messages. Must be awaited before any other call.
runWithLocale
runWithLocale<R>(url: URL, fn: () => R | Promise<R>, getConfig?: GetRequestConfigFn): Promise<R>
Runs a function inside an isolated per-request context. Uses AsyncLocalStorage on Node.js for concurrency-safe SSR. Falls back to a global variable on runtimes without it (Cloudflare Workers, Deno).
getLocale
getLocale(): string
Returns the current locale string set by setRequestLocale. Throws if called before setting the locale.
getTranslations
getTranslations<T>(namespace?: string)
Returns a typed t(key, values?) function scoped to the given namespace. The optional values object replaces {curlyOpen}varName{curlyClose} placeholders. Also exposes t.markup(key, tags | {curlyOpen} values?, tags {curlyClose}) for HTML interpolation with optional variable substitution.
getTranslationsReact
getTranslationsReact<T>(namespace?: string)
Same as getTranslations but returns a function with t.rich(key, tags) for React node interpolation.
getDefaultLocale
getDefaultLocale(): string
Returns the defaultLocale configured via the integration options or createIntlMiddleware. Defaults to "en" if not set.
getLocales
getLocales(): string[]
Returns the array of locales configured via the integration options or createIntlMiddleware. Returns an empty array if not set.
isValidLocale
isValidLocale(locale: string): boolean
Checks whether a locale string is in the configured locales list. Returns true for any string if no locales have been configured.
getMessages
getMessages(): Record<string, unknown>
Returns the full messages object for the current request. Throws if called before setRequestLocale.
createIntlMiddleware
createIntlMiddleware(options: { locales: string[]; defaultLocale?: string })
Creates an Astro middleware that automatically calls setRequestLocale on every request. Also sets locales and defaultLocale in the intl store, so you don't need to pass them in astro.config.mjs. Import from astro-intl/middleware.
RequestConfig Type returned by your getRequestConfig function. Contains locale: string and messages: Record.
IntlConfig Configuration type for the integration options. Contains defaultLocale: string — the locale used when the URL has no language prefix.
Primitive Union type for interpolation values: string | number | boolean | null | undefined. Used as the value type in the values object passed to t() and t.markup().
DotPaths<T> Utility type that generates all valid dot-notation paths for a given messages object. Used internally to provide autocomplete for translation keys.
Examples
Using Namespaces
Namespaces let you split large translation files into logical sections. Pass the namespace name to getTranslations.
---
import { setRequestLocale, getTranslations } from "astro-intl";
import getRequestConfig from "../../i18n/request";
await setRequestLocale(Astro.url, getRequestConfig);
// Each call is scoped to its namespace
const tNav = getTranslations("nav");
const tHero = getTranslations("hero");
const tFooter = getTranslations("footer");
---
<nav>{tNav("home")}</nav>
<h1>{tHero("title")}</h1>
<footer>{tFooter("description")}</footer>Interpolation
Replace {curlyOpen}varName{curlyClose} placeholders in your translations with dynamic values at runtime.
---
import { setRequestLocale, getTranslations } from "astro-intl";
import getRequestConfig from "../../i18n/request";
await setRequestLocale(Astro.url, getRequestConfig);
const tHero = getTranslations("hero");
// en.json: { "hero": { "greeting": "Hello, {name}! You have {count} items." } }
---
<!-- Simple interpolation -->
<p>{tHero("greeting", { name: "John", count: 3 })}</p>
<!-- "Hello, John! You have 3 items." -->
<!-- Markup + interpolation combined -->
<p set:html={tHero.markup("welcome", {
values: { name: "John" },
tags: {
link: (chunks) => `<a href="/docs">${chunks}</a>`,
}
})} />
<!-- en.json: "welcome": "Hi {name}, check our <link>docs</link>" -->
<!-- Result: "Hi John, check our <a href="/docs">docs</a>" -->Type-Safe Keys
Pass your messages type as a generic to getTranslations to get full autocomplete and type checking on translation keys.
import type en from "../i18n/messages/en.json";
// Pass the type of your messages JSON
type HeroMessages = typeof en["hero"];
const tHero = getTranslations<HeroMessages>("hero");
// ✅ Autocomplete works — "title", "description", etc.
tHero("title");
// ❌ TypeScript error — key doesn't exist
tHero("nonExistentKey");Reading the Current Locale
Use getLocale() anywhere after setRequestLocale has been called — for example to build locale-aware URLs.
---
import { getLocale } from "astro-intl";
const locale = getLocale(); // "en" | "es"
---
<!-- Build locale-prefixed URLs -->
<a href={`/${locale}/docs`}>Documentation</a>
<a href={`/${locale}/about`}>About</a>