Skip to content

Add content internationalization#1553

Open
timmy-why wants to merge 1 commit into
Thinkmill:mainfrom
timmy-why:feat/content-i18n
Open

Add content internationalization#1553
timmy-why wants to merge 1 commit into
Thinkmill:mainfrom
timmy-why:feat/content-i18n

Conversation

@timmy-why

Copy link
Copy Markdown

Hey! 👋 This PR adds content internationalization to Keystatic — here's the gist.

Adds content i18n so you can author a collection or singleton in multiple languages.

Set i18n on the config, mark an entry localized, and drop a {locale} token into its path:

export default config({
  i18n: {
    locales: { en: 'English', pl: 'Polski', de: 'Deutsch' },
    defaultLocale: 'en',
  },
  collections: {
    posts: collection({
      label: 'Posts',
      localized: true,
      path: 'content/posts/{locale}/*',
      slugField: 'title',
      schema: { /* … */ },
    }),
  },
});

Each language gets its own file/folder on disk (content/posts/en/…, content/posts/pl/…), but the entry still shows up once in the navigation — a language switcher at the top of the sidebar decides which one you're editing. Entries without a {locale} token stay shared across all languages. Note this localizes your content, which is separate from the existing locale option that just sets the Admin UI language.

Since the token is just a folder in the path prefix, you can turn this on for an existing multi-language project without moving any files.

The Reader API reads one language at a time — createReader/createGitHubReader now take a { locale } option:

const reader = createReader(process.cwd(), config, { locale: 'en' });

Config is validated up front, so a localized entry without a {locale} token (or vice versa), a missing i18n, or a bad defaultLocale all fail with a clear error instead of breaking later.

Scoped to @keystatic/core. Docs are in the new Internationalization page, and there are tests for the path/token handling and locale-scoped reads.

Demo

Here's the flow end to end — opening a singleton and a collection while switching languages: for a singleton the form fields below swap to the selected locale's values automatically, and for a collection the list filters down to that language's entries:

i18n.showcase.mov

And the same language switcher above the navigation with GitHub mode, so you can see it isn't tied to local mode:

github mode i18n switch

@changeset-bot

changeset-bot Bot commented Jul 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1d161f7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@keystatic/core Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@eremannisto

Copy link
Copy Markdown
Contributor

This would be amazing! ⬆️

@maxmorozoff

Copy link
Copy Markdown

Nice work so far. Could you also extend this to support Astro's prefixDefaultLocale: false behavior?

@timmy-why

Copy link
Copy Markdown
Author

Nice work so far. Could you also extend this to support Astro's prefixDefaultLocale: false behavior?

Thanks! Before I look into it — could you share what you're after with this? Just want to understand the use case.

The reason I ask: Astro's prefixDefaultLocale is about page URLs, not content folders. Keystatic content already works fine with a prefixDefaultLocale: false site whether you keep language folders (en/, pl/ — just examples here) or not, so this wouldn't unlock anything new — it'd only change how files sit on disk (default language without an en/ folder, translations in subfolders).

The main case I can think of is an existing repo whose default content already sits at the collection root, where you'd want to add translations without moving those files into en/ first. Worth noting that migrating is a one-time move, and Git preserves history through it:

mkdir -p content/posts/en
git mv content/posts/*.mdoc content/posts/en/

Then switch the collection path from content/posts/* to content/posts/{locale}/* and add your other locales alongside.

There's also a catch with collections that use the ** path wildcard (it lets a collection store entries across nested subfolders, with slugs like en/post-1). Say a collection is set up as content/posts/**, with a folder per language:

content/posts/en/post-1.mdoc
content/posts/pl/post-1.mdoc
content/posts/de/post-1.mdoc

Removing the folder for the default language would put the English entries directly under content/posts/, while pl/ and de/ stay as subfolders. Since ** scans every subfolder, listing the English entries would also sweep in the pl/ and de/ ones. So it'd need some extra handling to keep the languages separate.

Is it the existing-repo case, or something else you're running into?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants