Documentation
Internationalization
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
| Locale | Code | Marketing URL prefix | Example |
|---|---|---|---|
| English (default) | en | (none) | /, /docs, /terms |
| Spanish | es | /es | /es/docs, /es/terms |
| German | de | /de | /de/docs |
| French | fr | /fr | /fr/docs |
| Brazilian Portuguese | pt-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 → enFirst 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:
| Layer | Location | Contains |
|---|---|---|
| Structure | src/features/marketing/marketing-structure.ts | IDs, hrefs, tech refs, ordering, non-translatable data |
| Copy | messages/{locale}/marketing.json | Headlines, 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") };
}Locale-aware links
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:
- Write the English MDX file
- Add sibling files for each locale (
.de.mdx,.es.mdx,.fr.mdx,.pt-BR.mdx) - 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()insrc/lib/seo/metadata.ts hreflangalternates for every supported locale plusx-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
| Surface | Control | Effect |
|---|---|---|
| Marketing navbar | Language switcher | Navigates to prefixed URL + updates cookie |
| Settings → App → Appearance | Language select | Saves to DB via updateLanguagePreferenceAction, sets cookie, refreshes app locale |
First visit to / | Accept-Language | One-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
| Area | Status |
|---|---|
| Marketing landing, legal pages | Fully translated |
| Documentation & changelog | Fully translated |
| Auth forms | Translated |
| Dashboard shell nav, settings | Translated |
| Overview page | Translated (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:
- Add the code to
localesinsrc/i18n/routing.ts(andlocaleLabels,localeToOpenGraphLocale,localePathPrefixif needed) - Create
messages/{locale}/with a fullindex.tsand every namespace JSON - Add localized MDX siblings for docs and changelog pages
- Update
meta.{locale}.jsonfiles undercontent/docs/ - Extend
parseAcceptLanguage()insrc/proxy.tsandresolve-locale.tsif 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.