How to Use EmDash’s Built-In i18n for Multilingual Sites

Configure EmDash's built-in i18n correctly from day one to avoid painful retrofitting for multilingual sites.

By Central
EmDash's code-first i18n architecture uses Portable Text and locale filtering for flexible multilingual content management.
Highlights
  • EmDash stores content as structured JSON with localized fields, avoiding content duplication across locales by default.
  • A multilingual EmDash site with five locales incurs roughly 40% additional D1 read operations per page load.
  • Total infrastructure cost for moderate traffic stays under $10 per month due to cheap reads and zero-idle scaling.

EmDash ships with internationalization baked in. No plugin, no third-party service. But “built-in” doesn’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’s how to do it right the first time.

Understanding EmDash’s i18n Architecture

EmDash stores content as structured JSON (Portable Text). Each content type — posts, pages, custom types — can have localized fields. The i18n layer is code-first: locale configuration lives in astro.config.mjscodecodecodecode, translation data in D1, and the frontend renders via Astro’s getCollectioncodecodecodecode with locale filtering. There’s no admin UI toggle for “make this field translatable.” You define that in the schema.

The MCP server makes EmDash uniquely suited for AI-driven translation workflows.

The key non-obvious point: EmDash does not duplicate content across locales by default. A single content entry can have a localecodecodecodecode field, or you can create separate entries per locale. The right choice 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.

Configuring Locales and Content Types

Start in astro.config.mjscodecodecodecode. Add a localescodecodecodecode array under mdashcodecodecodecode:

“`js

mdash: {

locales: [‘en’, ‘fr’, ‘de’, ‘ja’],

defaultLocale: ‘en’,

}

“`

This sets the list and the fallback. Now define which content types are translatable. In the collectionscodecodecodecode definition for each type, add a localecodecodecodecode field:

“`js

collections: {

posts: {

fields: {

title: { type: ‘string’, localized: true },

body: { type: ‘portableText’, localized: true },

slug: { type: ‘slug’, localized: true },

locale: { type: ‘string’, required: true },

}

}

}

“`

The localized: truecodecodecodecode 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.

Pro tip: Slugs must be localized if you want /en/aboutcodecodecodecode and /fr/a-proposcodecodecodecode. EmDash uses the slugcodecodecodecode field per locale automatically when you set localized: truecodecodecodecode. If you leave it false, the same slug applies to all languages, breaking URL structure.

Managing Translations with Portable Text

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 — a task well-suited for AI agents but error-prone for manual copy-paste.

EmDash’s built-in MCP server exposes a mdash_translatecodecodecodecode 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.

But there’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 — or use dynamic layouts.

URL Localization and SEO

EmDash does not automatically prefix URLs with locale codes. You control routing. In your Astro pages, use Astro.url.pathnamecodecodecodecode to detect the locale and serve the correct content. A common pattern:

“`astro

const { locale } = Astro.params;

const allPosts = await getCollection(‘posts’, ({ data }) => data.locale === locale);

“`

Then set up a [locale]/[...slug].astrocodecodecodecode route. This gives you clean URLs like /fr/mon-articlecodecodecodecode. For the default locale, you can optionally omit the prefix by redirecting /codecodecodecode to the default locale’s home. EmDash’s redirects module handles this easily.

SEO metadata — titlecodecodecodecode, descriptioncodecodecodecode, og:localecodecodecodecode — should be set per locale. EmDash’s built-in SEO fields are not auto-localized. You must define them as localized: truecodecodecodecode in the content type schema. Otherwise, all languages share the same codecodecodecode tag.

Hreflang tags are your responsibility. EmDash does not generate them. In your codecodecodecode, loop through available locales and output:

“`html

“`

You can derive the list from the localescodecodecodecode config and current slug.

Workflow Automation with MCP and CLI

The CLI tool mdashcodecodecodecode can create content entries for all locales in one command. For example:

“`

mdash create post –locale fr –from en –id 123

“`

This duplicates the English post with ID 123 into French, copying all non-localized fields and leaving localized fields empty for translation. It’s faster than doing it through the admin UI.

The MCP server also supports batch operations. A common pattern: use an AI agent to scan content, identify untranslated entries, and queue translation jobs. Because plugins run in sandboxed dynamic workers, the agent can’t accidentally modify core data.

Edge case: If you have a custom content type with a relationship field (e.g., a “related posts” link), the relationship reference is locale-agnostic. A French post can link to an English post. This is intentional — 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.

Common Pitfalls and Edge Cases

  1. Fallback logic. 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 defaultLocalecodecodecodecode. This adds an extra D1 read but avoids 404s.
  1. Media and assets. 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’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.
  1. Passkey authentication. 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’ll need to build custom middleware.
  1. Search. 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’ll need to implement it yourself using D1 queries with a WHERE locale = ?codecodecodecode clause.

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 — each locale adds to the database size. Plan for D1 storage costs approximately 0.10 USD per GB per month.

The Future of i18n in EmDash

Cloudflare has hinted at deeper i18n support — 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’s fine for teams with Astro experience. For less technical users, the lack of an admin UI for translations is the biggest gap.

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, “Translate all unpublished French posts to German and set them as drafts.” The agent reads the MCP skills file, understands the content model, and executes without human intervention. This isn’t a feature — it’s a capability that will define how multilingual sites are maintained in the next two years.

Questions answered
  • How do you configure locales in EmDash?Add a locales array under mdash in astro.config.mjs, specifying the list and default locale.
  • What is the key non-obvious point about EmDash's i18n?EmDash does not duplicate content across locales by default; you choose between single entries with localized fields or separate entries per locale.
  • Why must slugs be localized in EmDash?Localized slugs enable separate URLs per locale, like /en/about and /fr/a-propos, while non-localized slugs break URL structure.
Share This Article