Multi-language content as a built-in, not a bolt-on. Translate per-field or per-entry, share a single slug across locales (or use locale-specific slugs), fall back cleanly when a translation is missing, and let agents draft translations in place.
- The model in one paragraph
- Setting up a localized type
- Writing localized content
- Reading by locale
- Fallback chains
- Locale-specific slugs
- Recipes
The model in one paragraph
A type can declare a list of locales. Fields opt in with
localized: true. The default-locale value lives at the top level of
the entry's content (where any non-localized field lives); other
locales attach to a _locale sidecar keyed by locale code. On read,
pass ?locale=fr and ledric merges the right values onto the
top-level shape, walking your fallback chain when a translation is
missing.
The default locale is the source of truth. Translations are overlays.
Setting up a localized type
import { defineType, field } from '@ledric/schema';
export const blogPost = defineType('blog_post', {
title: field.string({ required: true, localized: true }),
slug: field.slug({ required: true, from: 'title' }),
body: field.markdown({ required: true, localized: true }),
hero: field.asset({ kinds: ['image'] }),
published_at: field.date()
}, {
display_field: 'title',
locales: ['en', 'fr', 'es'],
default_locale: 'en',
fallback: { fr: 'en', es: 'fr' }
});localized: true requires the type to declare locales. Trying to
create a type that has localized fields but no locales list fails
at defineType / create_type.
By chat:
Add French and Spanish locales to
blog_post. English is the default. French falls back to English; Spanish falls back to French then English. Maketitleandbodylocalized; leaveheroandpublished_atshared across all locales.
The agent calls alter_type setting locales, default_locale,
fallback, and flipping localized: true on the right fields.
Writing localized content
The default-locale values go at the top of content. Other locales
go under _locale.
{
"title": "Hello world",
"slug": "hello-world",
"body": "# Hello\n\nFirst post.",
"hero": "01941b2c...",
"published_at": "2026-04-15",
"_locale": {
"fr": {
"title": "Bonjour, monde",
"body": "# Bonjour\n\nPremier article."
},
"es": {
"title": "Hola, mundo",
"body": "# Hola\n\nPrimer artΓculo."
}
}
}Notes:
_localeis a top-level reserved key. Don't use it as a field name.- Only
localized: truefields go inside_locale.<lang>. Putting non-localized fields there silently does nothing. - The default-locale (
enhere) values stay at the top β never duplicated under_locale.en. - Partial translations are fine. French can override
titleonly and inheritbodyfrom the fallback chain.
To draft a translation, just pass the merged content via draft
with parent_version:
curl -X POST http://localhost:3000/rpc \
-H 'Authorization: Bearer lka_...' \
-H 'Content-Type: application/json' \
-d '{
"tool": "draft",
"args": {
"type": "blog_post",
"ref": { "type": "blog_post", "slug": "hello-world" },
"parent_version": 1,
"fields": {
"title": "Hello world",
"slug": "hello-world",
"body": "# Hello",
"_locale": {
"fr": { "title": "Bonjour, monde", "body": "# Bonjour" }
}
}
}
}'Reading by locale
Pass locale on read / find (or ?locale=fr on the HTTP
routes). ledric returns the projected shape β locale-specific values
hoisted to the top, fallback chain applied, _locale stripped from
the response.
const post = await client.read('blog_post/hello-world', { locale: 'fr' });
// post.fields.title === 'Bonjour, monde'
// post.fields.body === '# Bonjour\n\nPremier article.'
// post.fields.hero === '01941b2c...' (not localized β same as default)
// post.fields.published_at === '2026-04-15'curl 'http://localhost:3000/entries/blog_post/hello-world?locale=fr'The response carries locale: 'fr' so consumers know which view
they're looking at.
Without ?locale=, the default-locale values are returned β same
as a non-localized entry.
Fallback chains
fallback maps each locale to its parent in the resolution order.
When a localized field isn't set for the requested locale, ledric
walks the chain.
{
locales: ['en', 'fr', 'es'],
default_locale: 'en',
fallback: { fr: 'en', es: 'fr' }
}Reading ?locale=es:
- Look in
_locale.esfor the field. - Missing? Walk to
es's fallback (fr). Look in_locale.fr. - Still missing? Walk to
fr's fallback (en). enis the default β the value at the top level wins.
The fallback chain is per-field, not per-entry. Spanish can pick up the title from French (because that's translated) and the body from English (because French isn't, but English is). No "all-or-nothing" constraint.
If you set fallback to a self-cycle or unknown locale, defineType
rejects it.
Locale-specific slugs
By default, a single slug serves all locales β blog_post/hello-world
works whether you pass ?locale=fr or not. If you want
locale-specific URLs (/blog/fr/bonjour-le-monde), pass locale to
rename_entry:
npx ledric rename blog_post/hello-world bonjour-le-monde --locale frNow:
blog_post/hello-world?locale=frβ 301 βblog_post/bonjour-le-mondeblog_post/bonjour-le-monde?locale=frβ the French view of the entryblog_post/bonjour-le-monde?locale=enβ 404 (locale-specific slug only resolves under its locale)blog_post/hello-world(no locale) β English view, same as before
The default-locale slug stays the canonical one. Per-locale slugs
are aliases living in slug_history rows tagged with their locale.
Recipes
Translate an existing post
The post
blog_post/hello-worldis currently English-only. Draft a French translation in place β same slug, just localized title and body. The hero image and published_at carry over.
The agent reads the current entry, builds the new content with a
_locale.fr block carrying the translated title and body, and
calls draft with the right parent_version. Top-level fields
unchanged.
Add a locale to an existing type
Add
de(German) toblog_postlocales. German falls back to English. Don't translate any posts yet β I'll do it gradually.
The agent calls alter_type extending locales and fallback.
Existing entries don't need migration: their content's missing
_locale.de block just means German reads fall back to English.
Find untranslated posts
Show me every
blog_postthat doesn't have a French translation yet.
The agent runs find with full-content budget, filters client-side
for entries where _locale.fr is missing or empty, and reports
the slugs.
Localize the slug
Rename
blog_post/hello-world's French slug tobonjour-le-monde. Keep the English slug as it is.
The agent calls rename_entry with locale: 'fr' β the English
slug stays put, the French URL gets its own.
Drop a locale
Remove the Spanish locale from
blog_post. The translations aren't worth maintaining.
The agent calls alter_type removing es from locales and from
fallback. Existing _locale.es blocks become inert (the type
no longer recognises the locale, so they don't surface on read).
A subsequent migrate_entries with a _locale.es: null merge
patch can scrub them from storage if you want a clean slate.