Add a language
The frontend is set up for translation with
next-intl and ships in English only. This page adds
a second language, German (de) in the examples.
There is no locale in the URL. Each request picks its locale in
frontend/lib/i18n.ts: the locale cookie if it names a configured
locale, else the best match in the browser's Accept-Language header
(de-AT finds de), else the first locale in the list. <html lang>
follows it. og:locale stays en_US (set in app/layout.tsx), since
crawlers, which fetch the social card, send no Accept-Language; to make
it follow the visitor, replace that metadata export with a
generateMetadata that sets openGraph.locale from await getLocale(). One URL serves every language, so
search engines index the English pages (crawlers send no
Accept-Language). If you need each language indexed under its own URL,
switch to next-intl's i18n routing instead; this recipe does not cover it.
Every word the interface shows is in frontend/messages/en.json, the
landing page and the plans included: app/landing/content.ts and
app/pricing/plans.ts hold only their structure (rows, links, pictures,
prices, which features a plan has), which every language shares.
Steps
Add the locale to the config in
frontend/lib/i18n.ts:export const locales = ['en', 'de'] as const;Keep
enfirst: it is the fallback. Nothing outside this list is ever used, whatever the cookie says.Copy the messages.
cp frontend/messages/en.json frontend/messages/de.json, then translate the values,Landing(the landing page) andPlans(plan names, features and buttons) included. Keep every key:npm testfails whende.jsonhas a keyen.jsonlacks or the other way round, and a missing key would render as its path. Keep the placeholders and tags as they are:"inviteSent": "Einladung an {email} gesendet.", "checkEmailIntro": "Wir haben einen Link an <strong>{email}</strong> gesendet."{role, select, ...}inWorkspace.yourRoleandMembers.roleNamepicks a phrase per role; translate each branch. The syntax is ICU, described in next-intl's messages docs.Check the landing page and the plans. Their words came with step 2. Two things in the structure files are written in one language:
- An
imageyou set inapp/landing/content.tshas analt. To translate it, give the row analtkey next to itstitleinLanding.features.rows.<key>and pass{ ...image, alt: t(`features.rows.${key}.alt`) }as the row'simageinapp/page.tsx. - A plan's
priceinapp/pricing/plans.tsis shared by every language. Stripe charges the Price behindSTRIPE_PRO_PRICE_IDin its own currency, so the price stays as Stripe charges it; the period after it isPlans.period, orPlans.periodOncefor a plan bought once.
- An
Translate the emails, which stay English until you do.
The sign-in email,
frontend/lib/magic-link-email.ts, is sent fromsendVerificationRequestinfrontend/auth.tsduring the sign-in request, so the visitor's cookie is readable there. Add its words toen.jsonandde.json:"MagicLinkEmail": { "subject": "Sign in to {host}", "button": "Sign in", "ignore": "If you did not request this email you can safely ignore it." }Give
renderMagicLinkEmailacopy: { subject: string; button: string; ignore: string }parameter and use it for the subject, the text body, the heading, the button and the closing line in place of the English. InsendVerificationRequest(importgetTranslationsfromnext-intl/server):const t = await getTranslations('MagicLinkEmail'); const { subject, text, html } = renderMagicLinkEmail({ host: safeUrl.host, url: safeUrl.toString(), copy: { subject: t('subject', { host: safeUrl.host }), button: t('button'), ignore: t('ignore'), }, });The invite email,
backend/src/workspaces/invite-email.ts, is built by the backend, which has no locale. Add an optionallocaleto the invite request body, validated against the same list asfrontend/lib/i18n.ts; regenerateopenapi.jsonasbackend/CLAUDE.md"Commands" says and runnpm run generate-typesinfrontend/. IninviteActioninfrontend/app/settings/workspace-actions.ts, readconst locale = await getLocale();before therelay(...)call and sendlocalein the body (the callback insiderelayis not async), and pick the subject and body from a{ en: {...}, de: {...} }map inrenderInviteEmail. That is the inviter's language; to use the invitee's, store a locale per user (recipe 1) and read it there instead.
Add a language switcher, if visitors should choose rather than get their browser's language. The template does not ship one. Copy the workspace switcher (
frontend/components/WorkspaceSwitcher.tsxandswitchWorkspaceActioninfrontend/components/actions.ts): a form with a<select name="locale">oflocales, each shown in its own language withnew Intl.DisplayNames([l], { type: 'language' }).of(l), posting to a Server Action that checks the value withhasLocale(locales, value)fromnext-intl, sets theLOCALE_COOKIEcookie for a year withpath: '/'andsameSite: 'lax', and callsrevalidatePath('/', 'layout'). It works without JavaScript, and the page re-renders in the new language. Its label goes in the messages like any other. Put it in the footer (frontend/components/SiteFooter.tsx) or under Appearance infrontend/app/settings/page.tsx.Check it. Set the cookie in the browser's devtools (
locale=de) and reload: the text changes and<html lang="de">. Dates and numbers follow the locale too;frontend/lib/format.tstakes it as an argument.
What stays English
- Blog posts and the legal pages (
app/terms/,app/privacy/) are content: write a translated page rather than messages. site.descriptioninfrontend/lib/site.ts, the meta description on every page and the summary in/llms.txt. To translate it, move it to a message and read it ingenerateMetadata;/llms.txtcan stay English.- The personal workspace's name, "Personal", which the backend sets when
it creates the account. Rename it where the backend creates it, or show
a translated name for workspaces with
isPersonalin the frontend. - Error text from the backend. A form shows the backend's
messagewhen it sends one, throughapiErrorMessageinfrontend/lib/api-error.ts, and the backend's validation and conflict messages are English. Translate them there, in that one function: turn the status codes or messages you care about into message keys, and fall back to the action's own translated sentence. - The error thrown by a Server Action without form state (the backoffice
publish and admin toggles, sign out). It is shown only in development;
production shows
Error.title.
What fails if you skip a step
dein the cookie but not inlocales: the cookie is ignored and the page stays English.deinlocaleswithoutmessages/de.json: every request that picksdefails to render, becauselib/i18n-request.tsimports the file.- A key missing from
de.json:npm testfails; at runtime the key's path shows instead of the text and next-intl logsMISSING_MESSAGE.