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 configuration automatically — there is no separate locale setup in EmDash.
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.
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
- No cross-locale query complexity — 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 — no cross-locale mixing.
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”WordPress with WPML or Polylang
Section titled “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
Section titled “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:
# Import a French WXR exportemdash import wordpress export-fr.xml --execute --locale fr
# Match against existing English content by slugemdash import wordpress export-fr.xml --execute --locale fr --translation-of-locale enNext Steps
Section titled “Next Steps”- Querying Content — Full query API reference
- Working with Content — Admin content management
- Astro i18n routing — Astro’s routing configuration