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.
Configuration
Section titled “Configuration”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.
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
Section titled “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... | published01DEF... | mon-article | fr | 01ABC... | draft01GHI... | mi-entrada | es | 01ABC... | publishedThis design means:
- Per-locale slugs —
/blog/my-postand/fr/blog/mon-articlework 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
Querying Translated Content
Section titled “Querying Translated Content”Single entry
Section titled “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).
---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
Section titled “Fallback chain”When no content exists for the requested locale, EmDash follows the fallback chain defined in your Astro config. Given fallback: { fr: "en" }:
- Try the requested locale (
fr) - Try the fallback locale (
en) - 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:
---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)
Section titled “Taxonomies (categories, tags)”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.
Collection listing
Section titled “Collection listing”Filter a collection by locale:
---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
Section titled “Language Switcher”Use getTranslations to build a language switcher that links to existing translations of the current entry:
---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" },// ]Managing Translations in the Admin
Section titled “Managing Translations in the Admin”Content list
Section titled “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
Section titled “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
Section titled “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
Section titled “Content API”Locale parameter
Section titled “Locale parameter”All content API routes accept an optional locale query parameter:
GET /_emdash/api/content/posts?locale=frGET /_emdash/api/content/posts/my-post?locale=frWhen omitted, defaults to the configured default locale.
Creating translations via API
Section titled “Creating translations via API”Create a translation by passing locale and translationOf to the content create endpoint:
POST /_emdash/api/content/postsContent-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
Section titled “Listing translations”Retrieve all translations for a given entry:
GET /_emdash/api/content/posts/01ABC.../translationsReturns the translation group ID and an array of locale variants with their IDs, slugs, and statuses.
The CLI supports --locale flags on content commands:
# List French postsemdash content list posts --locale fr
# Get a specific entry in Frenchemdash content get posts my-post --locale fr
# Create a French translation of an existing entryemdash content create posts --locale fr --translation-of 01ABC...Seeding Multilingual Content
Section titled “Seeding Multilingual Content”Seed files express translations using locale and translationOf:
{ "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
Section titled “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.
URL Strategy
Section titled “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 → frUse getRelativeLocaleUrl from astro:i18n to build correct URLs regardless of routing mode.
Importing Multilingual Content
Section titled “Importing Multilingual Content”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:
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.
Next Steps
Section titled “Next Steps”- Querying Content — Full query API reference
- Working with Content — Admin content management
- Astro i18n routing — Astro’s routing configuration