{"id":98075,"date":"2026-10-11T12:38:00","date_gmt":"2026-10-11T16:38:00","guid":{"rendered":"https:\/\/overcentral.com\/en\/?p=98075"},"modified":"2026-09-29T08:14:46","modified_gmt":"2026-09-29T12:14:46","slug":"emdash-i18n-multilingual-sites-98075","status":"publish","type":"post","link":"https:\/\/overcentral.com\/en\/emdash-i18n-multilingual-sites-98075\/","title":{"rendered":"How to Use EmDash\u2019s Built-In i18n for Multilingual Sites"},"content":{"rendered":"<p><a href=\"https:\/\/emdash.dev\" target=\"_blank\" rel=\"noopener noreferrer\" data-iacss-external=\"1\">EmDash<\/a> ships with internationalization baked in. No plugin, no third-party service. But &#8220;built-in&#8221; doesn&#8217;t mean automatic. The architecture is flexible enough to handle everything from a simple two-locale blog to a complex multi-language site with shared content and conditional fallbacks. The catch: you have to wire it correctly from day one, because retrofitting i18n into a running EmDash instance is painful. Here\u2019s how to do it right the first time.<\/p>\n<h2>Understanding EmDash\u2019s i18n Architecture<\/h2>\n<p>EmDash stores content as structured JSON (Portable Text). Each content type \u2014 posts, pages, custom types \u2014 can have localized fields. The i18n layer is code-first: locale configuration lives in <code>astro.config.mjs<\/code>codecodecodecode, translation data in D1, and the frontend renders via Astro\u2019s <code>getCollection<\/code>codecodecodecode with locale filtering. There\u2019s no admin UI toggle for \u201cmake this field translatable.\u201d You define that in the schema.<\/p>\n<p>The key non-obvious point: EmDash does <strong>not<\/strong> duplicate content across locales by default. A single content entry can have a <code>locale<\/code>codecodecodecode field, or you can create separate entries per locale. The right <a href=\"https:\/\/overcentral.com\/en\/ichra-choice-arrangements-label-97925\/\" title=\"ICHRA Gets CHOICE Arrangements Label from CMS, SBA\" data-iacss-internal=\"1\">choice<\/a> depends on your content model. For a product catalog with shared descriptions (only the product name changes), use a single entry with localized fields. For blog posts where every sentence is unique, separate entries per locale are simpler.<\/p>\n<h2>Configuring Locales and Content Types<\/h2>\n<p>Start in <code>astro.config.mjs<\/code>codecodecodecode. Add a <code>locales<\/code>codecodecodecode array under <code>mdash<\/code>codecodecodecode:<\/p>\n<p>&#8220;`js<\/p>\n<p>mdash: {<\/p>\n<p>  locales: [&#8216;en&#8217;, &#8216;fr&#8217;, &#8216;de&#8217;, &#8216;ja&#8217;],<\/p>\n<p>  defaultLocale: &#8216;en&#8217;,<\/p>\n<p>}<\/p>\n<p>&#8220;`<\/p>\n<p>This sets the list and the fallback. Now define which content types are translatable. In the <code>collections<\/code>codecodecodecode definition for each type, add a <code>locale<\/code>codecodecodecode field:<\/p>\n<p>&#8220;`js<\/p>\n<p>collections: {<\/p>\n<p>  posts: {<\/p>\n<p>    fields: {<\/p>\n<p>      title: { type: &#8216;string&#8217;, localized: true },<\/p>\n<p>      body: { type: &#8216;portableText&#8217;, localized: true },<\/p>\n<p>      slug: { type: &#8216;slug&#8217;, localized: true },<\/p>\n<p>      locale: { type: &#8216;string&#8217;, required: true },<\/p>\n<p>    }<\/p>\n<p>  }<\/p>\n<p>}<\/p>\n<p>&#8220;`<\/p>\n<p>The <code>localized: true<\/code>codecodecodecode flag tells EmDash to store separate values per locale in the same record. Without it, the field is shared. This is where beginners screw up: they mark everything as localized, bloating the database, or they forget to mark critical fields like SEO meta descriptions and slugs.<\/p>\n<p><strong>Pro tip:<\/strong> Slugs must be localized if you want <code>\/en\/about<\/code>codecodecodecode and <code>\/fr\/a-propos<\/code>codecodecodecode. EmDash uses the <code>slug<\/code>codecodecodecode field per locale automatically when you set <code>localized: true<\/code>codecodecodecode. If you leave it false, the same slug applies to all languages, breaking URL structure.<\/p>\n<h2>Managing Translations with Portable Text<\/h2>\n<p>Portable Text is a block-content format that supports inline annotations, custom blocks, and nested structures. When a field is localized, each locale stores its own array of blocks. This means translating a block of rich text requires keeping the block structure intact while changing only the text content \u2014 a task well-suited for <a href=\"https:\/\/overcentral.com\/en\/rogue-ai-agents-liability-vacuum-97898\/\" title=\"Rogue AI agents expose liability vacuum as OpenAI faces claims\" data-iacss-internal=\"1\">AI agents<\/a> but error-prone for manual copy-paste.<\/p>\n<p>EmDash&#8217;s built-in MCP server exposes a <code>mdash_translate<\/code>codecodecodecode tool that can take a Portable Text block and generate localized versions. You point it at the source locale and target, and it returns a structured translation. This works well for straightforward text. For blocks that contain embedded images or custom components (e.g., a callout), the MCP tool preserves the structure and translates only the text nodes.<\/p>\n<p>But there&#8217;s a trap: the MCP tool does not validate that the translated text fits the original layout. If your French translation is 40% longer than the English source, a component with fixed width may overflow. Plan for text expansion \u2014 or use dynamic layouts.<\/p>\n<h2>URL Localization and SEO<\/h2>\n<p>EmDash does not automatically prefix URLs with locale codes. You control routing. In your Astro pages, use <code>Astro.url.pathname<\/code>codecodecodecode to detect the locale and serve the correct content. A common pattern:<\/p>\n<p>&#8220;`astro<\/p>\n<p>const { locale } = Astro.params;<\/p>\n<p>const allPosts = await getCollection(&#8216;posts&#8217;, ({ data }) =&gt; data.locale === locale);<\/p>\n<p>&#8220;`<\/p>\n<p>Then set up a <code>[locale]\/[...slug].astro<\/code>codecodecodecode route. This gives you clean URLs like <code>\/fr\/mon-article<\/code>codecodecodecode. For the default locale, you can optionally omit the prefix by redirecting <code>\/<\/code>codecodecodecode to the default locale&#8217;s home. EmDash&#8217;s redirects module handles this easily.<\/p>\n<p>SEO metadata \u2014 <code>title<\/code>codecodecodecode, <code>description<\/code>codecodecodecode, <code>og:locale<\/code>codecodecodecode \u2014 should be set per locale. EmDash&#8217;s built-in SEO fields are not auto-localized. You must define them as <code>localized: true<\/code>codecodecodecode in the content type schema. Otherwise, all languages share the same <code><title><\/code>codecodecodecode tag.<\/p>\n<p><strong>Hreflang tags<\/strong> are your responsibility. EmDash does not generate them. In your <code><\/code>codecodecodecode, loop through available locales and output:<\/p>\n<p>&#8220;`html<\/p>\n<p>&#8220;`<\/p>\n<p>You can derive the list from the <code>locales<\/code>codecodecodecode config and current slug.<\/p>\n<h2>Workflow Automation with MCP and CLI<\/h2>\n<p>The CLI tool <code>mdash<\/code>codecodecodecode can create content entries for all locales in one command. For example:<\/p>\n<p>&#8220;`<\/p>\n<p>mdash create post &#8211;locale fr &#8211;from en &#8211;id 123<\/p>\n<p>&#8220;`<\/p>\n<p>This duplicates the English post with ID 123 into French, copying all non-localized fields and leaving localized fields empty for translation. It&#8217;s faster than doing it through the admin UI.<\/p>\n<p>The MCP server also supports batch operations. A common pattern: use an <a href=\"https:\/\/overcentral.com\/en\/meta-muse-ai-agent-80441\/\" title=\"Meta Launches Muse AI Agent, Needs User Trust\" data-iacss-internal=\"1\">AI agent<\/a> to scan content, identify untranslated entries, and queue translation jobs. Because plugins run in sandboxed dynamic workers, the agent can\u2019t accidentally modify core data.<\/p>\n<p><strong>Edge case:<\/strong> If you have a custom content type with a relationship field (e.g., a &#8220;related posts&#8221; link), the relationship reference is locale-agnostic. A French post can link to an English post. This is intentional \u2014 but it means your frontend must handle mixed-locale references gracefully, perhaps by showing the fallback title if the referenced post is not available in the current locale.<\/p>\n<h2>Common Pitfalls and Edge Cases<\/h2>\n<ol>\n<li><strong>Fallback logic.<\/strong> EmDash does not automatically fall back to the default locale when a translation is missing. You must implement this in your Astro templates. A simple approach: query for the requested locale; if empty, query for <code>defaultLocale<\/code>codecodecodecode. This adds an extra D1 read but avoids 404s.<\/li>\n<\/ol>\n<ol>\n<li><strong>Media and assets.<\/strong> Images uploaded to the media library are not locale-aware. A blog post in French can reference the same image as the English version. That&#8217;s fine for most cases. But if you need locale-specific images (e.g., a screenshot with French UI), store the image reference in a localized field, or use a naming convention.<\/li>\n<\/ol>\n<ol>\n<li><strong>Passkey authentication.<\/strong> Passkeys are bound to the site, not the locale. A user with a passkey can access the admin in any language. The admin UI detects the browser language and switches automatically. This is usually desirable, but if you need per-locale user roles, you&#8217;ll need to build custom middleware.<\/li>\n<\/ol>\n<ol>\n<li><strong>Search.<\/strong> D1 supports SQL queries across locales. If you build a site-wide search, you must filter by locale or index all locales together. The built-in search in the admin panel only searches the currently active locale. For a public search endpoint, you&#8217;ll need to implement it yourself using D1 queries with a <code>WHERE locale = ?<\/code>codecodecodecode clause.<\/li>\n<\/ol>\n<p>Based on preliminary benchmarks from early beta users, a multilingual EmDash site with five locales incurs roughly 40% additional D1 read operations per page load compared to a monolingual site. However, total infrastructure cost for moderate traffic (10,000 visits per day) stays under $10\/month because reads are cheap and the site scales to zero during idle periods. The cost comes from storage \u2014 each locale adds to the database size. Plan for D1 storage costs approximately 0.10 USD per GB per month.<\/p>\n<h2>The Future of i18n in EmDash<\/h2>\n<p>Cloudflare has hinted at deeper i18n support \u2014 automatic fallback rendering, locale-aware caching, and maybe a visual translation management interface. But those are not here yet. The current architecture expects developers to handle the logic. That&#8217;s fine for teams with Astro experience. For less technical users, the lack of an admin UI for translations is the biggest gap.<\/p>\n<p>What most people overlook: the MCP server makes EmDash uniquely suited for AI-driven translation workflows. You can point an agent at your content and say, &#8220;Translate all unpublished French posts to German and set them as drafts.&#8221; The agent reads the MCP skills file, understands the content model, and executes without human intervention. This isn&#8217;t a feature \u2014 it&#8217;s a capability that will define how multilingual sites are maintained in the next two years.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>EmDash ships with internationalization baked in. No plugin, no third-party service. But &#8220;built-in&#8221; doesn&#8217;t mean automatic. The architecture is flexible enough to handle everything from a simple two-locale blog to a complex multi-language site with shared content and conditional fallbacks. The catch: you have to wire it correctly from day one, because retrofitting i18n into [&hellip;]<\/p>\n","protected":false},"author":7,"featured_media":100278,"comment_status":"closed","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"fifu_image_url":"https:\/\/cards.overcentral.com\/cards\/en\/98075.png","fifu_image_alt":"How to Use EmDash\u2019s Built-In i18n for Multilingual Sites","footnotes":""},"categories":[31],"tags":[],"class_list":["post-98075","post","type-post","status-publish","format-standard","has-post-thumbnail","category-technology"],"fifu_image_url":"https:\/\/cards.overcentral.com\/cards\/en\/98075.png","fifu_image_alt":"How to Use EmDash\u2019s Built-In i18n for Multilingual Sites","_links":{"self":[{"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/posts\/98075","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/users\/7"}],"replies":[{"embeddable":true,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/comments?post=98075"}],"version-history":[{"count":1,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/posts\/98075\/revisions"}],"predecessor-version":[{"id":100279,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/posts\/98075\/revisions\/100279"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/media\/100278"}],"wp:attachment":[{"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/media?parent=98075"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/categories?post=98075"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/tags?post=98075"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}