Skip to content

Internationalization (i18n)

EmDash integrates with Astro’s built-in i18n routing 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.

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.

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.

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

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

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>

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.

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:

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.

Terms are per-locale. Definitions (_emdash_taxonomy_defs) are also per-locale, so label / labelSingular can be translated too. 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:

---
import { getTaxonomyTerms, getEntryTerms } from "emdash";
const categories = await getTaxonomyTerms("category", {
locale: Astro.currentLocale,
});
const terms = await getEntryTerms("posts", post.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.

Filter a collection by locale:

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>

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

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:

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" },
// ]

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

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.

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.

All content API routes accept an optional locale query parameter:

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

When omitted, defaults to the configured default locale.

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

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.

Retrieve all translations for a given entry:

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

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

The CLI supports --locale flags on content commands:

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

Seed files express translations using locale and translationOf:

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

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.

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.

Import WordPress content through the admin migration tool — see Content Import and Migrate 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 and link it to the original:

Terminal window
emdash content create posts --locale fr --translation-of 01ABC...

This is the same --locale / --translation-of workflow shown in Seeding multilingual content above, applied after the import completes.