Documentation

Internationalization

How Motoko Base handles locales, routing, message catalogs, and SEO for multi-language SaaS products.

Open inChatGPT (opens in a new tab)Claude (opens in a new tab)Cursor (opens in a new tab)How Motoko Base handles locales, routing, message catalogs, and SEO for multi-language SaaS products.

Motoko Base ships with a complete internationalization (i18n) foundation built on next-intl. You get five locales, locale-aware marketing URLs, translated dashboard chrome, localized docs and changelog, and a clear pattern for extending translations to your own features.

This page explains how i18n is wired. For step-by-step instructions when you add copy to a feature, see Adding translations.

Supported locales

LocaleCodeMarketing URL prefixExample
English (default)en(none)/, /docs, /terms
Spanishes/es/es/docs, /es/terms
Germande/de/de/docs
Frenchfr/fr/fr/docs
Brazilian Portuguesept-BR/pt-br/pt-br/docs

Locale definitions live in src/i18n/routing.ts. Labels shown in the language switcher come from localeLabels in the same file.

Hybrid routing model

Motoko Base uses two routing strategies on purpose:

Locale-prefixed surfaces (marketing)

These routes live under src/app/[locale]/:

  • Landing page and marketing sections
  • Legal pages (/terms, /privacy, /licence)
  • Documentation (/docs)
  • Changelog (/changelog)

English uses unprefixed URLs (localePrefix: 'as-needed'). Other locales get a prefix (/fr, /de, /es, /pt-br).

The marketing language switcher (MarketingLanguageSwitcher) navigates between equivalent paths in each locale and keeps the current page context.

Unprefixed app surfaces

These routes stay at fixed paths with no locale segment:

  • Dashboard and demo features (/dashboard/...)
  • Auth flows (/sign-in, /sign-up, forgot/reset/verify)
  • API routes, webhooks, public redirects (/api, /r/[slug])

The active locale for these pages is resolved at request time — not from the URL.

Locale resolution

When next-intl needs a locale, src/i18n/resolve-locale.ts applies this priority:

Signed-in user  →  user_preferences.language (database)
Guest           →  NEXT_LOCALE cookie
Fallback        →  Accept-Language header
Default         →  en

First visit to /: src/proxy.ts reads Accept-Language once. If it matches a supported locale other than English, the visitor is redirected to the prefixed marketing URL and the locale cookie is set. After that, explicit choices (switcher or Settings) win.

Signed-in users: saving language in Settings → App → Appearance (/dashboard/settings/app/appearance) updates the database via updateLanguagePreferenceAction, sets the locale cookie, and refreshes the UI via LocalePreferenceSync.

File layout

src/i18n/
├── routing.ts          # Locales, prefixes, cookie name
├── navigation.ts       # Locale-aware Link, useRouter, usePathname
├── request.ts          # next-intl server config (loads messages)
├── resolve-locale.ts   # Locale resolution for app/auth routes
├── load-messages.ts    # Dynamic import of message catalogs
└── fumadocs.ts         # Docs/changelog content locales

messages/
├── en/
│   ├── index.ts        # Aggregates all namespaces
│   ├── common.json
│   ├── marketing.json
│   ├── auth.json
│   └── …               # One JSON file per feature/area
├── es/
├── de/
├── fr/
└── pt-BR/

content/docs/           # Localized MDX via dot suffix (e.g. page.de.mdx)
content/changelog/

Shared formatting helpers for dates and numbers live in src/lib/i18n/format.ts. Use these instead of hardcoding "en-US" in Intl calls.

Message catalogs

Translations are typed JSON namespaces under messages/{locale}/. Each locale's index.ts exports a single object consumed by next-intl:

// messages/en/index.ts
import common from "./common.json";
import auth from "./auth.json";
import overview from "./overview.json";

const messages = { common, auth, overview };
export default messages;

Use one namespace per feature or area (auth, settings, billing, overview, …). Keep keys stable across locales — only the string values change.

Marketing: structure vs copy

Marketing follows a split pattern:

LayerLocationContains
Structuresrc/features/marketing/marketing-structure.tsIDs, hrefs, tech refs, ordering, non-translatable data
Copymessages/{locale}/marketing.jsonHeadlines, descriptions, FAQ text, footer labels

Section components merge structure + messages in useMarketingContent().

Using translations in components

Client components

"use client";

import { useTranslations } from "next-intl";

export function MyPage() {
  const t = useTranslations("overview");

  return (
    <h1>{t("welcome.title", { name: "Alex" })}</h1>
  );
}

Server components and metadata

import { getTranslations } from "next-intl/server";

export async function generateMetadata() {
  const t = await getTranslations("docs");
  return { title: t("metaTitle") };
}

Import navigation helpers from @/i18n/navigation — not next/link — on locale-prefixed pages:

import { Link } from "@/i18n/navigation";

<Link href="/docs">Documentation</Link>

On dashboard and auth pages, standard Next.js links are fine — those routes are not locale-prefixed.

Localized docs and changelog

Documentation and changelog content uses Fumadocs i18n with the dot parser (page.de.mdx, page.es.mdx, …). English is the base file (page.mdx).

Sidebar order and section titles come from meta.json in each folder. Localized sidebar titles use meta.de.json, meta.fr.json, etc.

To add a localized docs page:

  1. Write the English MDX file
  2. Add sibling files for each locale (.de.mdx, .es.mdx, .fr.mdx, .pt-BR.mdx)
  3. List the page slug in the folder's meta.json (once — slugs are shared across locales)

SEO

Localized marketing, legal, docs, and changelog URLs get:

  • Per-locale canonical URLs via buildPageMetadata() in src/lib/seo/metadata.ts
  • hreflang alternates for every supported locale plus x-default
  • Sitemap entries for each locale in src/app/sitemap.ts

Open Graph locale tags map App locales to BCP 47 values in localeToOpenGraphLocale().

User-facing language controls

SurfaceControlEffect
Marketing navbarLanguage switcherNavigates to prefixed URL + updates cookie
Settings → App → AppearanceLanguage selectSaves to DB via updateLanguagePreferenceAction, sets cookie, refreshes app locale
First visit to /Accept-LanguageOne-time redirect to best matching locale

Auth pages inherit locale from cookie or Accept-Language — they do not have their own URL prefix.

What is translated today

AreaStatus
Marketing landing, legal pagesFully translated
Documentation & changelogFully translated
Auth formsTranslated
Dashboard shell nav, settingsTranslated
Overview pageTranslated (reference example)
Demo features (links, storage, billing, …)Translated UI strings — extend using the same pattern

Demo features are intentionally easy to copy: each has a JSON namespace in messages/{locale}/ that mirrors the English keys.

Adding or removing a locale

To add a locale beyond the five shipped:

  1. Add the code to locales in src/i18n/routing.ts (and localeLabels, localeToOpenGraphLocale, localePathPrefix if needed)
  2. Create messages/{locale}/ with a full index.ts and every namespace JSON
  3. Add localized MDX siblings for docs and changelog pages
  4. Update meta.{locale}.json files under content/docs/
  5. Extend parseAcceptLanguage() in src/proxy.ts and resolve-locale.ts if you want browser detection

Removing a locale is the reverse — delete its message folder, MDX siblings, and remove the code from routing.ts.


Next steps

Adding translations — Practical guide for new features, new keys, and server/client patterns.

Project structure — Where features and shared code live.

Configuration — Dashboard nav, billing labels, and other product config.