---
title: Internationalization (i18n)
description: Translate content into multiple languages with per-locale publishing, slugs, and automatic fallback.
---

import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";

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.

## Configuration

Enable i18n by adding an `i18n` block to your Astro config. EmDash reads this configuration automatically — there is no separate locale setup in EmDash.

```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.

## 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.

```
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
- **No cross-locale query complexity** — list queries return entries for one locale only

## Querying Translated Content

### Single entry

Pass `locale` to `getEmDashEntry` to retrieve a specific translation. When omitted, it defaults to the request's current locale (set by Astro's i18n middleware).

```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.redirect("/404");
---

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

### Fallback chain

When no content exists for the requested locale, EmDash follows the fallback chain defined in your Astro config. Given `fallback: { fr: "en" }`:

1. Try the requested locale (`fr`)
2. Try the fallback locale (`en`)
3. Try the default locale

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

<Aside>
  When a fallback is used, the response metadata includes `fallbackLocale` so your template can display a "this content is not yet translated" notice.
</Aside>

### 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.data.slug}`}>{post.data.title}</a></li>
  ))}
</ul>
```

## 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);
---

<nav aria-label="Language">
  <ul>
    {translations.map((t) => (
      <li>
        <a
          href={getRelativeLocaleUrl(t.locale, `/blog/${t.slug}`)}
          aria-current={t.locale === Astro.currentLocale ? "page" : undefined}
        >
          {t.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.entry.id);
// translations: [
//   { locale: "en", id: "01ABC...", slug: "my-post", status: "published" },
//   { locale: "fr", id: "01DEF...", slug: "mon-article", status: "draft" },
// ]
```

<Aside type="tip">
  Use `getRelativeLocaleUrl` from `astro:i18n` to build locale-prefixed URLs. This respects your Astro routing strategy (`prefix-other-locales` or `prefix-always`).
</Aside>

## Managing 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

When creating a translation, the new entry is pre-filled with data from the source locale and assigned a default slug of `{source-slug}-{locale}`. Adjust the slug and content as needed, 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.

## Content API

### Locale parameter

All content API routes accept an optional `locale` query parameter:

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

When omitted, defaults to 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

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

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.

## CLI

The CLI supports `--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 of an existing entry
emdash content create posts --locale fr --translation-of 01ABC...
```

## Seeding 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.

## Field Translatability

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

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

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

## URL Strategy

EmDash does not manage locale URLs — Astro handles routing. Common patterns:

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

# prefix-always
/en/blog/my-post       → en
/fr/blog/mon-article   → fr
```

Use `getRelativeLocaleUrl` from `astro:i18n` to build correct URLs regardless of routing mode.

## Importing Multilingual Content

### WordPress with WPML or Polylang

The WordPress plugin import source detects WPML and Polylang automatically. When detected, imported content includes locale and translation group metadata, preserving the multilingual structure.

### WXR files

WXR exports do not include WPML/Polylang metadata. Import as a single locale and create translations manually, or use the `--locale` flag to assign a locale to all imported items:

```bash
# Import a French WXR export
emdash import wordpress export-fr.xml --execute --locale fr

# Match against existing English content by slug
emdash import wordpress export-fr.xml --execute --locale fr --translation-of-locale en
```

## Next Steps

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