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

An entry has two identifiers with different purposes:

  • entry.id is the entry’s slug. Use it when building the public URL.
  • 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.

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.

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

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

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:

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:

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.

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.id}`}>{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);
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:

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

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.

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.

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.

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

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.

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.

After authenticating the CLI, use its --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 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 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

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.

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, that routing strategy prevents the injected admin pages from loading.

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:

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

The same alternates belong in the <head> of every content page. If your layout uses <EmDashHead>, 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:

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

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.redirect("/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 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 as a draft and link it to the original database ID:

Terminal window
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, applied after the import completes.