FFD keeps English prose as translation keys and stores the Danish interface copy in flat JSON dictionaries. Published Docs and Blog content instead has complete Markdown counterparts, because headings, tables, and long explanations need review as documents.
Files you will touch:
apps/web/src/lib/i18n/locales/da.json, for interface copy in the web appapps/api/src/lib/i18n/da.json, for authored api errors and emailapps/web/src/content/docs/da/orapps/web/src/content/blog/da/, for published contentpackages/shared/src/locale.ts,apps/web/src/lib/i18n/i18n.ts, andapps/api/src/lib/i18n/index.ts, only when you change the languages you ship
Translate interface copy
Use the English sentence as the key. Server Components get the request-bound
translator with getTranslation():
const t = await getTranslation()
return <h1>{t('Account settings')}</h1>
Client Components use useTranslation() from @/lib/i18n/use-translation. That
wrapper is the only translation hook; noRestrictedImports blocks the react-i18next
hook and <Trans>. It returns t for plain copy and tx for a sentence that contains
markup:
const { t, tx } = useTranslation()
return <button type="button">{t('Save changes')}</button>
tx keeps the marked-up sentence as one key, so the translator decides where the tag
sits in Danish word order:
tx('If you want to learn more about me, check out my <about>about page</about>.', {
about: <Link to="/about" className="link" />,
})
Each entry in the map is either an element, which receives the tag's own text as its children, or a function of that text when a wrapper needs it in a prop:
tx('Reach out by <email>email</email>.', {
email: (text) => <a href={`mailto:${appConfig.contactEmail}`}>{text}</a>,
})
A tag with no entry in the map renders as literal text, so a dictionary entry cannot introduce markup of its own. Nested tags are not supported.
Write a key with the ASCII apostrophe rather than ’, escaped inside the single
quotes: t('Don\'t have an account?'). Biome reformats a single-quoted string that
contains an apostrophe to double quotes; keep the formatted result, because the key
text is what has to match the dictionary.
Add each exact English key to the Danish JSON dictionary in the same change. The i18n
configuration sets keySeparator: false and nsSeparator: false, so punctuation and
dots remain part of the key. English needs no dictionary entry because i18next returns
the key itself when the active locale is en.
Translate published content
English files stay at their existing paths. Put a Danish counterpart under the da
folder with the same relative path and filename. For example:
src/content/docs/setup/stripe.md
src/content/docs/da/setup/stripe.md
Keep frontmatter control fields, links, slugs, directives, code, and commands identical. Translate frontmatter titles and descriptions together with all body prose. The collection derives public slugs from English, so both languages keep the same URL. If a counterpart is missing at runtime, the collection renders the whole English entry instead of mixing languages within one page.
Browser language and purchase language
getLocale() reads the ffd-locale cookie and otherwise negotiates the browser's
Accept-Language header. The language switcher writes that cookie. Pass the resulting
Locale to every apiFetch() call; the client forwards it as Accept-Language, which
lets the api localize authored errors.
Checkout also includes locale in CheckoutSessionRequest. The api snapshots it on
the Purchase, and transactional emails use that stored value. It also rewrites
users.locale, which carries the language for mail about an account's access rather
than about one purchase. The cookie controls the current browser request, while the
purchase locale records the language chosen when the buyer paid.
Check coverage
pnpm i18n:report
pnpm check
pnpm typecheck
pnpm i18n:report reports missing dictionary keys and missing Markdown counterparts
for every locale in LOCALES beyond English. It is a report rather than a build gate.
Treat every listed gap as unfinished work and update English and every translated locale
in the same change.
Change the languages you ship
English is the key language and the structural source, permanently, because the
translation keys are English sentences the code's readers share. Your language is a
translation layer above English, and Danish ships in full as the template you copy.
docs/adr/0037-danish-ships-as-the-template-a-fork-copies.md records the decision.
Add or replace a language
Name the language in packages/shared/src/locale.ts. LOCALES is the set, and
LOCALE_DETAILS carries what a locale needs beyond its code: the name the language calls
itself, and the BCP 47 tag Intl formats its dates and money with.
export const LOCALES = ['en', 'da', 'es'] as const
export const LOCALE_DETAILS: Record<Locale, { name: string; tag: string }> = {
en: { name: 'English', tag: 'en-US' },
da: { name: 'Dansk', tag: 'da-DK' },
es: { name: 'Español', tag: 'es-ES' },
}
Copy one dictionary per app and translate its values. The web dictionaries sit in
apps/web/src/lib/i18n/locales/, so da.json becomes es.json there, and the api keeps
its dictionary beside the registry, so apps/api/src/lib/i18n/da.json becomes
apps/api/src/lib/i18n/es.json.
Register each copy in the DICTIONARIES record its app builds resources from. In
apps/web/src/lib/i18n/i18n.ts:
import es from './locales/es.json'
const DICTIONARIES: Record<
Exclude<Locale, typeof DEFAULT_LOCALE>,
ResourceLanguage
> = {
da: { translation: da },
es: { translation: es },
}
In apps/api/src/lib/i18n/index.ts the record is the same and the import is
import es from './es.json'.
Copy the content directories and translate them: apps/web/src/content/docs/da/ to
apps/web/src/content/docs/es/, and apps/web/src/content/blog/da/ to
apps/web/src/content/blog/es/. Keep relative paths, filenames, and frontmatter control
fields identical, as in "Translate published content" above.
Then run pnpm typecheck and pnpm i18n:report. The report names each locale in its
output, so the remaining work reads as Missing es: and Missing es content: lines.
Replacing Danish rather than adding beside it is the same edit with a rename: change the
entry in LOCALES and LOCALE_DETAILS, rename both dictionaries and both content
directories, update the two imports, and translate in place.
Ship one language
Set LOCALES = ['en'], delete the da entry from LOCALE_DETAILS, delete both
da.json dictionaries together with their imports and DICTIONARIES entries, and delete
apps/web/src/content/docs/da/ and apps/web/src/content/blog/da/.
The language control then disappears on its own, because LanguageSelect renders nothing
below two locales at all six of its mount sites. English needs no dictionary and no
counterpart, so the fallback carries the whole app.
What you do not edit
The rest of the seam derives from LOCALES and LOCALE_DETAILS, so a language change
stops at the files above. The date and money formatters in both apps, and the language
control in the header, read the BCP 47 tag off LOCALE_DETAILS rather than naming a
language themselves. The callout labels reach a page through t like any other copy.
The content loader, the build validator, and the translation report derive their locale
directories and their iteration from LOCALES without en.
Related
- Add an api route, including the required locale
- The billing model, where checkout stores locale
- Database schema, both locale columns
