All pages

Maintain translations

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:

  1. apps/web/src/lib/i18n/locales/da.json, for interface copy in the web app
  2. apps/api/src/lib/i18n/da.json, for authored api errors and email
  3. apps/web/src/content/docs/da/ or apps/web/src/content/blog/da/, for published content
  4. packages/shared/src/locale.ts, apps/web/src/lib/i18n/i18n.ts, and apps/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.

Updated