# Internationalization (i18n)

> Translate content into multiple languages with per-locale publishing, slugs, and automatic fallback.

Source: https://emdashcmseverything.com/official-docs/guides/internationalization/

EmDash integrates with [Astro's built-in i18n routing](https://docs.astro.build/en/guides/internationalization/) to provide multilingual content management. Astro handles URL routing and locale detection; EmDash handles translated content storage and retrieval.

Each translation is a full, independent content entry with its own slug, status, and revision history. The French version of a post can be in draft while the English version is published.

## Configure locales

Enable i18n by adding an `i18n` block to your Astro config. EmDash reads this same configuration for its locale list, default locale, and fallback chain.

```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import emdash, { local } from "emdash/astro";
import { sqlite } from "emdash/db";

export default defineConfig({
	i18n: {
		defaultLocale: "en",
		locales: ["en", "fr", "es"],
		fallback: { fr: "en", es: "en" },
	},
	integrations: [
		emdash({
			database: sqlite({ url: "file:./data.db" }),
			storage: local({
				directory: "./uploads",
				baseUrl: "/_emdash/api/media/file",
			}),
		}),
	],
});
```

When `i18n` is not present in the Astro config, all i18n features are disabled and EmDash behaves as a single-language CMS.

> **Do not prefix the default locale**
>
> Any Astro routing strategy that prefixes the default locale breaks the EmDash admin. This includes `routing: { prefixDefaultLocale: true }` and `routing: "prefix-always"`. These strategies require a locale prefix on page routes injected by integrations, so `/_emdash/admin` and its setup, login, and content pages return **404**. A locale-prefixed path such as `/en/_emdash/admin` does not work because the admin router is mounted at `/_emdash/admin`. API routes under `/_emdash/api/*` are unaffected.
>
> Use Astro's default routing strategy (`prefix-other-locales`) instead, which serves the default locale unprefixed and works with the admin:
>
> ```js title="astro.config.mjs"
> i18n: {
> 	defaultLocale: "en",
> 	locales: ["en", "fr", "es"],
> 	fallback: { fr: "en", es: "en" },
> 	// no `routing` block — the default (prefix-other-locales) is compatible
> },
> ```
>
> If public URLs must include the default locale, add a redirect or rewrite in front of the site (for example, `/` to `/en/`) rather than enabling `prefixDefaultLocale`.

## How translations work

EmDash uses a **row-per-locale** model. Each translation is its own row in the database with its own ID, slug, and status, linked to other translations via a shared `translation_group` identifier. A posts table with three translations looks like this:

```
ec_posts:
id       | slug        | locale | translation_group | status
---------|-------------|--------|-------------------|----------
01ABC... | my-post     | en     | 01ABC...          | published
01DEF... | mon-article | fr     | 01ABC...          | draft
01GHI... | mi-entrada  | es     | 01ABC...          | published
```

This design means:

- **Per-locale slugs** — `/blog/my-post` and `/fr/blog/mon-article` work naturally
- **Per-locale publishing** — publish the English version while keeping French in draft
- **Per-locale revisions** — each translation has its own revision history
- **Single-locale queries** — list queries return entries for one locale only

### Slugs, entry IDs, and database IDs

An entry has two identifiers with different purposes:

- `entry.id` is the slug, prefixed with the locale whenever the locale's URLs are prefixed (`en/my-post` for a non-default locale, `my-post` for the default locale unless `prefixDefaultLocale` is set). Use it as is when entries are routed at the site root, such as `/${entry.id}`.
- `entry.data.slug` is the slug without a locale. Use it when the route already adds the locale segment, and when you pass a slug to `getEmDashEntry()`.
- `entry.data.id` is the database ID. Use it for API operations and helpers that refer to a stored content row, including `getTranslations()` and `getEntryTerms()`.

Translations have different database IDs because each locale is a separate row. Their shared `translation_group` records that the rows are translations of the same content. EmDash manages that group when you create a translation; templates normally only need the database ID of any row in the group.

## Query translated content

### Single entry

Pass `Astro.currentLocale` to `getEmDashEntry` on a multilingual route. Astro knows the locale selected by its router, while EmDash needs the explicit value to disambiguate slugs that can exist in more than one locale. Do the same for collection queries.

```astro title="src/pages/[...slug].astro"
---
import { getEmDashEntry } from "emdash";

const { slug } = Astro.params;
const { entry: post, error } = await getEmDashEntry("posts", slug, {
  locale: Astro.currentLocale,
});

if (!post) return Astro.rewrite("/404");
---

<article>
  <h1>{post.data.title}</h1>
</article>
```

### Fallback chain

When a matching published entry does not exist in the requested locale, `getEmDashEntry` follows the fallback chain from the Astro config. In preview or visual-editing mode, the same lookup can return a draft. Given `fallback: { fr: "en" }`:

1. Try the requested locale (`fr`)
2. Try the fallback locale (`en`)
3. Try the default locale if it is not already in the chain

Fallback only applies to single-entry queries. List queries return entries for the requested locale only.

Each fallback lookup uses the same `id` argument. For example, a request for the slug `about` can fall back from French to an English entry whose slug is also `about`. A request for `a-propos` cannot discover an English entry whose slug is `about`; the two rows use different public identifiers. Use `getTranslations()` to find and link locale variants with different slugs.

> **Note**
>
> When a fallback is used, `fallbackLocale` contains the locale that supplied the entry. Use it to explain that the requested translation is unavailable.

### Menus

Menus are per-locale — the same `name` (e.g. `"primary"`) can exist in several
locales, all linked via a shared `translation_group`. Menu items resolve their
content references against the active locale's version of the referenced
content.

The following component fetches the primary menu for the active locale:

```astro title="src/components/PrimaryNav.astro"
---
import { getMenu } from "emdash";

const menu = await getMenu("primary", { locale: Astro.currentLocale });
---

<nav aria-label="Primary">
  <ul>
    {menu?.items.map((item) => (
      <li><a href={item.url}>{item.label}</a></li>
    ))}
  </ul>
</nav>
```

Create translations of an existing menu from the admin's **Menus** list — the
items are cloned with `reference_id` intact (it stores the referenced content's
`translation_group`), so the new menu's links point at the right per-locale
content automatically.

### Taxonomies (categories, tags)

Terms are per-locale. Each locale can have its own definition, so `label` and
`labelSingular` can be translated too, while `hierarchical` and `collections`
are shared by every locale (see
[Translate taxonomies and terms](https://emdashcmseverything.com/official-docs/guides/taxonomies/#translate-taxonomies-and-terms)). The pivot
`content_taxonomies.taxonomy_id` stores the term's `translation_group`, so a
single assignment spans every locale of the content.

The following example fetches categories and a post's terms for the active locale:

```astro
---
import { getTaxonomyTerms, getEntryTerms } from "emdash";

const categories = await getTaxonomyTerms("category", {
  locale: Astro.currentLocale,
});
const terms = await getEntryTerms("posts", post.data.id, undefined, {
  locale: Astro.currentLocale,
});
---
```

Translating a piece of content automatically inherits the source's term
assignments — you only need to translate the *terms themselves* once, and every
post that uses them resolves to the right locale at read time.

#### Repairing taxonomy locale mismatches

When the admin loads its site manifest, EmDash warns in the server logs when
taxonomy definitions or terms use a locale that is not in the site's configured
`i18n.locales`. Without an `i18n` configuration, `en` is the effective locale.
These rows are left unchanged because EmDash cannot infer which configured
locale the existing content was meant to use.

Back up the database, then inspect the affected rows named in the warning:

```sql
SELECT id, name, locale FROM _emdash_taxonomy_defs ORDER BY name, locale;
SELECT id, name, slug, locale FROM taxonomies ORDER BY name, slug, locale;
```

After confirming the intended locale for each row, update it by `id`:

```sql
UPDATE _emdash_taxonomy_defs SET locale = 'ja' WHERE id = '<definition-id>';
UPDATE taxonomies SET locale = 'ja' WHERE id = '<term-id>';
```

Use the exact casing from `i18n.locales`. Before updating, check for a row with
the same taxonomy name and target locale, or the same term name, slug, and target
locale. Those combinations are unique; if a target row already exists, reconcile
the translations instead of applying a bulk locale update. Restart EmDash and
confirm that the warning no longer appears.

### Collection listing

Filter a collection by locale:

```astro title="src/pages/posts.astro"
---
import { getEmDashCollection } from "emdash";

const { entries: posts } = await getEmDashCollection("posts", {
  locale: Astro.currentLocale,
  status: "published",
});
---

<ul>
  {posts.map((post) => (
    <li><a href={`/${post.id}`}>{post.data.title}</a></li>
  ))}
</ul>
```

## Build a language switcher

Use `getTranslations` to build a language switcher that links to existing translations of the current entry:

```astro title="src/components/LanguageSwitcher.astro"
---
import { getTranslations } from "emdash";
import { getRelativeLocaleUrl } from "astro:i18n";

interface Props {
  collection: string;
  entryId: string;
}

const { collection, entryId } = Astro.props;
const { translations } = await getTranslations(collection, entryId);
const publishedTranslations = translations.filter(
  (translation): translation is typeof translation & { slug: string } =>
    translation.status === "published" && translation.slug !== null
);
---

<nav aria-label="Language">
  <ul>
    {publishedTranslations.map((translation) => (
      <li>
        <a
          href={getRelativeLocaleUrl(translation.locale, `/blog/${translation.slug}`)}
          aria-current={translation.locale === Astro.currentLocale ? "page" : undefined}
        >
          {translation.locale.toUpperCase()}
        </a>
      </li>
    ))}
  </ul>
</nav>
```

The `getTranslations` function returns all locale variants in the same translation group:

```ts
const { translationGroup, translations } = await getTranslations("posts", post.data.id);
// translations: [
//   { locale: "en", id: "01ABC...", slug: "my-post", status: "published" },
//   { locale: "fr", id: "01DEF...", slug: "mon-article", status: "draft" },
// ]
```

> **Tip**
>
> `getTranslations()` returns draft and scheduled siblings as well as published ones. Filter the result before rendering a public language switcher so it does not link to an unpublished route.

## Manage translations in the admin

### Content list

When i18n is enabled, the content list shows:

- A **locale column** displaying each entry's locale
- A **locale filter** in the toolbar to switch between locales

### Creating translations

Open any content entry in the editor. The sidebar displays a **Translations** panel listing all configured locales. For each locale:

- **"Translate"** appears for locales without a translation — click to create one
- **"Edit"** appears for locales with an existing translation — click to navigate to it
- The current locale is marked with a checkmark

Selecting **Translate** creates the translation as a draft and opens it. The draft starts with the source entry's data and the same slug, which is allowed because slugs are unique within each locale. If the translation needs a localized slug, such as `mon-article` for `my-post`, edit the slug. Adjust the content, then save.

### Per-locale publishing

Each translation has its own status. Publish, unpublish, or schedule translations independently. The French version can be in draft while the English version is live.

## Use the content API

### Locale parameter

Content API routes require an authenticated session or bearer token. List routes accept an optional `locale` query parameter. A single-entry route also accepts it when the path uses a slug; database IDs are globally unique and do not need locale disambiguation.

```http
GET /_emdash/api/content/posts?locale=fr
GET /_emdash/api/content/posts/my-post?locale=fr
```

When a list request omits `locale`, it uses the configured default locale.

### Creating translations via API

Create a translation by passing `locale` and `translationOf` to the content create endpoint:

```http
POST /_emdash/api/content/posts
Content-Type: application/json
X-EmDash-Request: 1

{
  "locale": "fr",
  "translationOf": "01ABC...",
  "slug": "mon-article",
  "data": {
    "title": "Mon Article"
  }
}
```

`translationOf` is the source row's database ID, such as `entry.data.id`. The new entry shares the source entry's `translation_group` and starts as a draft.

### Listing translations

Retrieve all translations for a given entry:

```http
GET /_emdash/api/content/posts/01ABC.../translations
```

Returns the translation group ID and an array of locale variants with their IDs, slugs, and statuses.

## Use the CLI

After authenticating the CLI, use its `--locale` flags on content commands:

```bash
# List French posts
emdash content list posts --locale fr

# Get a specific entry in French
emdash content get posts my-post --locale fr

# Create a French translation as a draft
emdash content create posts \
  --locale fr \
  --translation-of 01ABC... \
  --slug mon-article \
  --data '{"title":"Mon article"}' \
  --draft
```

`content create` requires input from `--data`, `--file`, or `--stdin`. It publishes after creation unless you pass `--draft`.

## Seed multilingual content

Seed files express translations using `locale` and `translationOf`:

```json title=".emdash/seed.json"
{
  "content": {
    "posts": [
      {
        "id": "welcome",
        "slug": "welcome",
        "locale": "en",
        "status": "published",
        "data": { "title": "Welcome" }
      },
      {
        "id": "welcome-fr",
        "slug": "bienvenue",
        "locale": "fr",
        "translationOf": "welcome",
        "status": "draft",
        "data": { "title": "Bienvenue" }
      }
    ]
  }
}
```

The source locale entry must appear before its translations in the seed file so that `translationOf` references resolve correctly.

## Choose which fields are translatable

Each field has a `translatable` setting (default: `true`). When creating a translation:

- **Translatable fields** are pre-filled from the source locale for editing
- **Non-translatable fields** are copied and kept in sync across all translations in the group

On a collection with revisions, publishing an entry copies the non-translatable values it changed to the other translations, and saving a draft changes only that entry. If another translation has a pending draft that changed one of those values, the draft keeps its own value, and publishing that translation copies it to the rest of the group.

System fields like `status`, `published_at`, and `author_id` are always per-locale and never synced.

A [reference field](https://emdashcmseverything.com/official-docs/guides/relations/) is shared rather than synced: EmDash keys its links by the translation group, so every translation of an entry has one selection, and editing it from any locale changes it for all of them.

> **Note**
>
> Non-translatable fields are useful for values that should stay consistent across locales, such as a product SKU or a sort order number.

## Build locale URLs

EmDash stores the locale; Astro handles public routing. The supported EmDash configuration leaves the default locale unprefixed:

```
# prefix-other-locales (Astro default)
/blog/my-post          → en (default locale, no prefix)
/fr/blog/mon-article   → fr
```

Use `getRelativeLocaleUrl` from `astro:i18n` to add the correct prefix and any custom locale path mapping. Do not enable a default-locale prefix; as described in [Configure locales](#configure-locales), that routing strategy prevents the injected admin pages from loading.

## Sitemaps

The per-collection sitemap at `/sitemap-{collection}.xml` is locale-aware. It includes published entries from routable, SEO-enabled collections. Deleted entries, entries without a slug, and entries marked `noindex` are excluded. Each included translation becomes its own `<url>` entry. EmDash builds its path from the collection's `urlPattern`, then applies Astro's locale prefix and any custom locale `path` mapping.

Translation siblings are cross-linked with `xhtml:link` alternates so search engines can serve the correct language to each user:

```xml title="/sitemap-post.xml"
<url>
  <loc>https://example.com/blog/hello</loc>
  <lastmod>2026-05-28T16:33:15.461Z</lastmod>
  <xhtml:link rel="alternate" hreflang="en" href="https://example.com/blog/hello" />
  <xhtml:link rel="alternate" hreflang="fr" href="https://example.com/fr/blog/bonjour" />
  <xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/blog/hello" />
</url>
```

Siblings are grouped by `translation_group`, so a published locale variant appears as an alternate on every other published, indexable variant. Locales missing from `i18n.locales` are omitted because Astro has no route for them. Sites with a single locale produce a plain sitemap with no `xhtml` namespace.

## Add `hreflang` links to the page head

The same alternates belong in the `<head>` of every content page. If your layout uses [`<EmDashHead>`](https://emdashcmseverything.com/official-docs/plugins/creating-plugins/hooks/#pagemetadata), this is automatic: when i18n is enabled and the page context includes `content`, it emits one `<link rel="alternate">` per published translation sibling — including a self-referencing link, as Google recommends — plus `x-default`:

```html
<link rel="alternate" hreflang="en" href="https://example.com/blog/hello" />
<link rel="alternate" hreflang="fr" href="https://example.com/fr/blog/bonjour" />
<link rel="alternate" hreflang="x-default" href="https://example.com/blog/hello" />
```

For hand-rolled heads, resolve the alternates with `getHreflangAlternates`:

```astro title="src/pages/blog/[slug].astro"
---
import { getEmDashEntry, getHreflangAlternates } from "emdash";

const { entry, error } = await getEmDashEntry("posts", Astro.params.slug, {
	locale: Astro.currentLocale,
});
if (error) return new Response("Server error", { status: 500 });
if (!entry) return Astro.rewrite("/404");

const alternates = await getHreflangAlternates("posts", entry.data.id, {
	siteUrl: Astro.url.origin,
});
---

<head>
	{alternates.map((a) => <link rel="alternate" hreflang={a.hreflang} href={a.href} />)}
</head>
```

Behaviour matches the sitemap:

- **`x-default`** points at the default-locale variant. When the default locale has no published translation, it falls back to the first routable variant, so the set never lacks an `x-default`.
- **Unpublished siblings are excluded** — draft translations never leak into alternates.
- **`noindex` siblings are excluded.** If the current entry is `noindex`, no alternates are returned.
- **Unroutable locales are dropped.** A row whose locale isn't in your configured `i18n.locales` can't be served, and linking search engines to a 404 is worse than no link.
- **Untranslated entries** still get a self-referencing alternate and `x-default` when i18n is enabled, mirroring the sitemap.
- With **i18n disabled**, the result is empty and no queries run.

URLs are built from the collection's `urlPattern` and localized through the Astro i18n configuration. `getHreflangAlternates()` needs an absolute site URL. It uses `siteUrl` from the call or the site settings URL; without either, it returns an empty array because `hreflang` links must be absolute.

## Import multilingual content

Import WordPress content through the admin migration tool — see [Content Import](https://emdashcmseverything.com/official-docs/migration/content-import/) and [Migrate from WordPress](https://emdashcmseverything.com/official-docs/migration/from-wordpress/). A WXR export does not carry the locale and translation-group structure that WPML or Polylang add, so imported content lands in your default locale.

To build translations from imported content, create the translated entry as a draft and link it to the original database ID:

```bash
emdash content create posts \
  --locale fr \
  --translation-of 01ABC... \
  --slug mon-article \
  --data '{"title":"Mon article"}' \
  --draft
```

This is the same `--locale` and `--translation-of` relationship used by [seed files](#seed-multilingual-content), applied after the import completes.

## Next steps

- [Querying Content](https://emdashcmseverything.com/official-docs/guides/querying-content/) — Full query API reference
- [Working with Content](https://emdashcmseverything.com/official-docs/guides/working-with-content/) — Admin content management
- [Astro i18n routing](https://docs.astro.build/en/guides/internationalization/) — Astro's routing configuration
