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 configuration automatically — there is no separate locale setup in EmDash.

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.

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

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 — no cross-locale mixing.

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.

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

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