Multilingual (i18n)
Per-entry locale variants stored in a dedicated locale column. Same slug across all languages — no slug suffixes, no separate tables.
Setup
In the admin, go to Settings → Language and set the Locales field:
en,de,fr
The first value is the default locale. Additional values are translated locales. Save settings — the locale switcher appears automatically in the entry editor.
How it works
Every entry has a locale column in the database. The default locale is stored as an empty string ('') for backwards compatibility — all entries created before enabling i18n are treated as the default locale automatically.
_entries ├── slug: "my-post", locale: "" ← default locale (e.g. English) ├── slug: "my-post", locale: "de" ← German translation └── slug: "my-post", locale: "fr" ← French translation
Creating translations
- Open any entry in the editor.
- The locale bar appears below the toolbar with a tab for each configured locale. Solid tabs are existing variants; dashed tabs are untranslated.
- Click a dashed tab to create that translation — a blank entry is created for the same slug in that locale.
- Fill in the translated content and save.
Each locale variant is independent: it has its own status (draft/published), its own version history, and its own data fields.
Reading locale content
In your Astro pages, use the locale-aware functions from orbiter:collections:
import { getCollection, getLocaleCollection, getLocaleEntry, locale, locales }
from 'orbiter:collections';
// Default locale only (the primary language)
const posts = await getCollection('posts');
// All German posts
const dePosts = await getLocaleCollection('posts', 'de');
// A specific post in German — falls back to default locale if not translated
const post = await getLocaleEntry('posts', 'my-post', 'de');Locale-aware static paths
---
import { getCollection, getLocaleEntry, locales } from 'orbiter:collections';
export async function getStaticPaths() {
const posts = await getCollection('posts'); // default locale entries only
return posts.flatMap(post =>
locales.map(loc => ({
params: { lang: loc, slug: post.slug },
}))
);
}
const { lang, slug } = Astro.params;
const post = await getLocaleEntry('posts', slug, lang);
---
<html lang={lang}>
<h1>{post.data.title}</h1>
</html>Translate missing locales with AI
With an AI provider configured, the editor's locale bar offers ✦ Translate missing (FR, ES) on the default-language entry, and ✦ Translate from default with AI on a language tab that doesn't exist yet. Each click creates the missing versions as drafts:
- Prose fields (
stringandrichtextfields, plus the SEO title and description) are translated; HTML, Markdown, URLs, slugs, media and other fields are copied unchanged. - An existing translation is never overwritten, and nothing is published — review the draft, then publish it (the review workflow applies as usual).
- Needs permission for the collection. Entries over about 24,000 characters are refused instead of being cut off. The text is sent to your configured provider; with Ollama it stays local.
- API:
POST /api/ai/translatewith{ collection, slug, from, to }. The activity log shows the draft as created by you (AI translation).
Fallback behaviour
getLocaleEntry() tries the requested locale first. If no translation exists for that locale, it returns the default locale variant. This lets you translate incrementally — untranslated entries fall back gracefully.
Accessing locale config
import { locale, locales } from 'orbiter:collections';
// locale → 'en' (the configured default locale)
// locales → ['en', 'de', 'fr'] (all configured locales)Use locales to build language switchers or generate <link rel="alternate"> hreflang tags.
Admin API — locale query param
The REST API accepts a ?locale= query parameter on all entry endpoints:
# List German entries
GET /api/collections/posts/entries?locale=de
# Get a specific German entry
GET /api/collections/posts/entries/my-post?locale=de
# List all locales for an entry
GET /api/collections/posts/entries/my-post/locales
# Create a German variant
POST /api/collections/posts/entries
{ "slug": "my-post", "locale": "de", "data": { "title": "Mein Beitrag" } }