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
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
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-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
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
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
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
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
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
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
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
Locale parameter
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.
Creating translations via API
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.
Listing translations
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.
CLI
The CLI supports --locale flags on content commands:
# 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...
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
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
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.
Importing Multilingual Content
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
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 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
Next Steps
- Querying Content — Full query API reference
- Working with Content — Admin content management
- Astro i18n routing — Astro’s routing configuration