Sign-ups are closed. Menestrel now powers the sites built by Agora Studio, a web agency based in France.

Multilingual Astro sites: routing, content, and the per-locale trap

Astro's i18n routing is solid. The hard part is the content model behind it, and the pricing model in front of it, where most CMSes charge per language.

A bilingual site is the normal case in most of Europe, not an advanced feature. A French business with any exposure to tourism, exports or expatriates wants a second language, and often a third.

Astro handles the routing part well. The two things that actually cause pain are the content model behind it and, less obviously, the pricing model in front of it.

The part Astro gives you

Astro’s built-in i18n routing covers the mechanics:

export default defineConfig({
  i18n: {
    defaultLocale: 'fr',
    locales: ['fr', 'en'],
    routing: { prefixDefaultLocale: false },
  },
});

That gives you /services/ for French and /en/services/ for English, plus helpers to build locale-aware links. It is unopinionated and gets out of your way, which is the right design.

What it deliberately does not do is tell you how content should be shaped, which is where the real decisions live.

Decision one: is a translation a separate entry, or one entry with several values?

This is the fork everything else follows from, and picking wrong costs you a migration later.

Separate entries means services/plomberie.md and services/plumbing.md are two files, two rows, two independent things linked by some key. It is simple, each locale can have its own structure, and one language can exist without the other. The cost is that the link between them is a convention you maintain, and “which pages are missing an English version” becomes a question nobody can answer without a script.

One entry, several values per field means a single entry whose title is { fr: "Plomberie", en: "Plumbing" }. The link is structural, so nothing can drift. Completeness is answerable: the system knows a field is missing for a locale. The cost is that the two languages are bound to the same shape, and a field that only makes sense in one language is awkward.

We went with the second, per field, and pushed it one step further: each locale carries its own publication status. French can be live while English is still a draft, on the same entry. That sounds like a detail until you watch a real client work, because the alternative forces them to either publish an empty English page or hold the French version hostage to a translation that has not arrived.

Decision two: which fields are actually translated

Not everything is. A price is a price. An opening time is an opening time. A photo is usually the same photo.

Marking every field as localised produces an editing experience where the client tabs through two versions of a number that is identical in both, and it is the fastest way to make them hate the tool. Marking too few means an English page with French text in the middle.

The rule that holds up: localise what a human would rewrite, not what a human would retype. Titles, summaries, body text, SEO descriptions, alt text: yes. Numbers, dates, image choices, links: no, until proven otherwise.

In practice that is a per-field flag:

title: fields.text({ required: true, localized: true }),
price: fields.number({ unit: '€' }),
photo: fields.image(),

Three fields, one localised. The editor sees language tabs on the title and a single value for the other two, which is what they expect.

Decision three: what happens when a translation is missing

Three options, and the wrong one is the default in most setups.

Fall back to the default locale. The English page shows French text. Visitors see something, and it is confusing enough that many leave. Search engines see duplicated content across two URLs claiming to be different languages.

404 the missing page. Honest, and it breaks your language switcher: the reader clicks “English” and lands on an error, which reads as a broken site.

Do not link to what does not exist. The language switcher only offers languages that actually have a published version of this page, and the sitemap only lists what exists. Nobody ever meets the missing page.

The third is more work and it is the only one that respects the reader. It requires the CMS to know, per entry and per locale, whether something is published, which loops back to decision one.

The hreflang detail everyone gets wrong

Once the same content exists at two URLs, search engines need telling. The rules are simple and consistently misapplied:

And the one that catches people building multilingual sites with a CMS: only declare versions that are actually published. Pointing hreflang at a draft is worse than pointing at nothing, because you have told a crawler to index a page that returns a 404.

This is why publication status per locale is not a nicety. It is the thing your hreflang tags depend on being true.

The trap in front of all this: per-locale pricing

Here is the part nobody mentions until the invoice.

Surveyed at the source on 8 August 2026: Storyblok includes 2 locales on its Growth plan at $99 a month, then charges $20 a month for each additional locale. Contentful gives 2 locales on free and 3 on Lite, and Lite is €300 a month. Prismic gives 2 locales on free, up to 8 on its $675 tier.

Read that again in the context of a European brochure site. A French business that adds English and German is asking for three locales. On Storyblok that is $99 plus $20 a month, forever, for one site. That single add-on costs more than an entire Menestrel Studio plan covering ten sites.

The logic is understandable: locales are an easy upsell because customers who need them are usually larger. It is simply the wrong shape for the market we are in, where bilingual is baseline rather than premium.

Our locales are unlimited from the first paid plan, which is a positioning choice as much as a technical one. The free plan caps at two, and that cap is the one thing about our free tier we would change if it cost us nothing.

What the loader gives your pages

On the rendering side, the content arrives through a standard Content Layer loader, so nothing about your Astro code is special. Entries carry their locale, and translations are addressable, which is what you need for the language switcher and the hreflang tags.

The practical shape is: fetch the entries for the current locale, render them, and for each one ask which other locales have a published version. That last question is the one a CMS should answer for you, and the one a folder of markdown files cannot.

Which language should live at the root

A question with no universally right answer, and one worth deciding deliberately rather than by accident.

Default locale at the root (/services/ for French, /en/services/ for English) keeps your primary market’s URLs short and preserves any existing link equity if you are adding a language to a live site. It is what prefixDefaultLocale: false gives you. The cost is asymmetry: one language has clean URLs and the other looks like an afterthought, which occasionally irritates the client whose market is the prefixed one.

Every locale prefixed (/fr/services/ and /en/services/) is symmetric, honest, and easier to reason about when a third language arrives. The cost is that adding it to an existing site means redirecting every old URL, and the root now needs to decide where to send someone.

That root decision is its own trap. Redirecting / based on the browser’s Accept-Language header feels helpful and breaks two things: a shared link now lands different people on different pages, and crawlers, which send no language preference, see whatever your fallback is. If you do it, do it only on the home page, honour an explicit choice stored by the visitor, and never redirect a deep link.

We made both choices on our own site: English at the root because the market is larger, French under /fr/, and a redirect on the home page only, overridden by the visitor’s stored preference.

Sitemaps and the missing-translation problem, again

The same rule that governs your language switcher governs your sitemap: list what exists, and only declare alternates that are published.

The tempting shortcut is a sitemap generator configured to emit hreflang pairs for every URL by pattern. It works when your paths match across locales and quietly lies when they do not. Our own French legal pages live at /fr/conditions/ while the English ones are at /terms/, so a pattern-based pairing would have produced alternates for half the site and silence for the rest. We turned that option off and let each page declare its own pairs, which it can do accurately because it knows its own translations.

What we do not do

No automatic translation. We removed a DeepL integration from the plan in July 2026, deliberately. Machine translation of a client’s marketing copy produces text that reads as machine-translated, and billing per character for it added a metered line to a product whose entire pitch is a price you do not think about. If you want it, translate outside and paste in.

No per-locale workflows. No translator role, no review queue, no assignment. If you run a translation team with deadlines, you want a tool built for that, and it is not us.

No locale-specific structure. Both languages share one shape, which is the price of the model we chose.

If you are weighing this against a specific tool, the Storyblok comparison covers the locale pricing in detail, and the pricing page answers the awkward questions directly.

Back to the blog