Now with AI-powered page building via MCP Server

i18n

Every field is multilingual by default. How locales shape URLs, which one a request resolves to, and what happens when a translation is missing.

You do not model translations in cmssy. Every field is multilingual already: content is stored keyed by language code, so one page carries all its languages and one block instance carries all of its.

That removes the usual duplication problem - there is no "English page" and "German page" to keep in sync - and replaces it with two smaller questions. Which locale does a request resolve to, and what shows when a field has not been translated yet?

The locale set

Locales come from your site config, not from your code:

const { defaultLocale, locales } = await resolveSiteLocales();

Adding a language is a workspace setting. Your app picks it up on the next request, which means a new locale does not need a deploy - only translations.

URLs: the default locale has no prefix

This is the rule everything else follows from:

localizedPath("/pricing", "en", "en"); // "/pricing"    default: bare
localizedPath("/pricing", "de", "en"); // "/de/pricing" others: prefixed
localizedPath("/", "de", "en");        // "/de"

Keeping the default locale unprefixed means your canonical URLs never change when you add a second language - existing links, shares and rankings all survive.

Resolving a request

The reverse direction reads the first path segment, and it is deliberately strict:

splitLocaleFromPath(["de", "pricing"], { defaultLocale: "en", locales: ["en", "de"] });
// -> { locale: "de", path: ["pricing"] }

splitLocaleFromPath(["pricing"], { defaultLocale: "en", locales: ["en", "de"] });
// -> { locale: "en", path: ["pricing"] }

A first segment counts as a locale only when it is in locales and is not the default. So /en/pricing is not the English pricing page - it is a page whose slug happens to start with en. One canonical URL per page per language, no duplicates.

The catch-all route does this before looking up a slug:

const { path: strippedPath } = splitLocaleFromPath(path, await resolveSiteLocales());
const slug = "/" + (strippedPath ?? []).join("/");

Missing translations

A field falls back rather than disappearing:

pickLocalized(value, locale, defaultLocale);
// value[locale] ?? value[defaultLocale] ?? first available value

Three steps: the requested language, then the default, then whatever exists. The last one matters - a field translated only into French still renders on a German page rather than leaving a hole.

The consequence for block authors: a partially translated page is a normal state, not an error. Write components that render sensibly when a field comes back in the wrong language, and test with a locale you have not translated - it is the first thing your visitors will hit in a new language.

Middleware

export const proxy = createCmssyProxy(cmssy, {
  // Only if your URLs carry the language AND your routes are static paths
  // rather than a catch-all.
  stripLocalePrefix: true,
});

The SDK is explicit about this: leave it off for a catch-all app that reads the language off the path itself. Turn it on anyway and the route receives a path with no prefix, so splitLocaleFromPath finds nothing to strip and falls back to the default locale - every language renders as the default one.

The middleware does still resolve the language and pass it on in a header, so an app can read it from there instead. But if your route derives the locale from the path, as a catch-all normally does, those two are now telling it different things.

Declare the language in HTML

Set <html lang> from the resolved locale. It is what screen readers and translation tools read, and it is a contract - which is why the editor smoke test asserts against it rather than searching for a word in your copy. Copy is content; an editor can rewrite it at any time, and then the test lies.

Next steps

  • SEO - hreflang alternates built from the same locale set.
  • Testing - the <html lang> assertion.
  • Data models - records are multilingual too.