Languages

Every string a user can read lives in src/locales/, one JSON file per language. Shipping in a language other than English is filling a file, not editing components.

Built on next-intl. No account, no translation service, no vendor: the files are plain JSON in that library's standard layout, so pointing Crowdin or anything else at them later is your call and costs nothing today.

Adding a language

Two edits and a file.

// src/i18n/routing.ts
export const routing = defineRouting({
  locales: ["en", "it", "de"],
  defaultLocale: "en",
  localePrefix: "as-needed",
  localeDetection: false,
})
cp src/locales/en.json src/locales/de.json   # then translate the values

That is the whole setup. The language picker reads the list from routing, so the new entry appears on its own, named in its own language.

You do not have to translate all of it. Any key you leave out falls back to English rather than rendering a gap or the key itself, and the merge goes all the way down: translating docs.title does not blank its siblings. Ship a file with ten keys in it and the other nine hundred stay English.

Shipping in one language

The kit arrives with two, English and Italian, and that is deliberate: a scaffold that has never run with a second language is untested code, and the first person to add one would find the bugs. If your product needs one language, removing the second is three deletions:

# 1. drop "it" from routing.locales in src/i18n/routing.ts
rm src/locales/it.json
rm content/docs/*.it.md

The language picker disappears on its own once there is a single locale left, and the URLs do not move: the default language never had a prefix.

URLs

localePrefix: "as-needed" keeps the default language exactly where it is: /pricing, not /en/pricing. Only the other languages carry a prefix, so adding a language never invalidates a URL you already have indexed.

localeDetection: false means no automatic redirect from Accept-Language. A visitor who opens an English link reads English, and a link you paste in a chat resolves the same way for everyone in it. The switch is a visible control instead.

One thing to know if you localize more of the app: the provider has to sit inside [locale], with its locale and messages passed explicitly. There is a second NextIntlClientProvider in the root layout, for the pages that live outside the locale prefix, and it is not enough on its own: a root layout is not re-rendered when a segment below it changes, so moving across languages leaves every client component holding the one it was loaded with. The server half of the page switches and the client half does not, which looks exactly like a translation that stopped halfway and is invisible on a reload.

What is not localized, and why

The dashboard, the admin panel and the sign-in pages sit outside src/app/[locale]/. /it/dashboard does not exist. That is a simplification and a security property at once: src/proxy.ts matches private routes by prefix, and a locale segment in front of /dashboard would stop those prefixes matching, so every private route would need to remember to strip it.

Their strings are still in the message files, so localizing them is routing work rather than a rewrite: move those route groups under [locale] and teach proxy.ts to strip the locale before it compares.

The placeholder legal pages keep their text inline. You are replacing them with your own policy anyway, and a legally binding document assembled from a file that silently substitutes another language for a missing paragraph is worse than one written in a single language on purpose.

Content in your database is not covered either: plan names and descriptions come from Plan rows, so they render in whatever language you seeded. If you need those translated, read them through a key like plans.<slug>.description with the database value as the fallback. No schema change, and it stays out of your way until you want it.

Copy that differs between deployments

Some sections read differently on the kit's own marketing site than in the app it ships as. Those carry both variants under $kit and $product:

"docs": {
  "title": "Documentation",
  "$kit": { "intro": "Everything you need to go from git clone to production." },
  "$product": { "intro": "Guides for getting the most out of your app." }
}

KIT_SITE is fixed at build time, so only one branch can ever render. The build drops the other one entirely instead of shipping it: without that, every page would serialise copy it cannot use, and a clone would carry the kit's own marketing wording inside its HTML where anyone could read it. Siblings of the two markers are shared and stay.

You will not need this unless you run two deployments off one codebase. If you do, the markers are $-prefixed because a message key cannot start with $, so they can never collide with a real key of yours.

Translating the Markdown docs

A translated guide is a file beside its source with the language in the name:

content/docs/getting-started.md      # the source
content/docs/getting-started.it.md   # the Italian version

Nothing else registers it. A page with no variant for the requested language is served in English with a note saying so, so translating one guide and leaving the other six is a supported state rather than a half-finished one.

Translations carry frontmatter even where their source has none, because they have to declare two things:

---
title: Primi passi
description: Da git clone all'app che gira, in una decina di minuti.
translated_from: getting-started.md
source_checksum: 3954fb6a1cdc
---

source_checksum is a short hash of the English file this translation was written from. You do not have to compute it: run the check below and it prints the value to paste.

Then, before every release:

npm run check:translations

It exits non-zero when the English file has changed since a translation recorded it, and prints the current checksum for each one that needs updating.

The marker is a hash of the content rather than a git revision on purpose. A commit hash only means something inside the history it was made in, so it would stop resolving the moment you clone this kit and start your own history. Hashing the file itself keeps the marker valid in any repository, and needs no git at all, which is why it also works on a source archive downloaded from a release. Line endings are normalised before hashing, so the same file gives the same checksum on Windows and on Linux. This is the check worth keeping. A stale translation never breaks: it keeps rendering, keeps looking finished, and quietly becomes instructions for an older version of your product. The only way to catch it by reading is to open both files and compare, so nobody does.

Dates, currency and plurals

Three things that look like formatting and behave like translation:

  • Dates go through Intl.DateTimeFormat with the active locale, never a hand-written "en-US". A language that writes the day first shows the wrong date otherwise, and it is wrong in a way that reads as correct.
  • Currency goes through Intl.NumberFormat, same reason.
  • Plurals use ICU ({count, plural, one {# post} other {# posts}}), not a ternary. The ternary is right for English and wrong for most languages, several of which have three or four forms.

Checks that run on their own

npm test fails when a key read from the code is missing from en.json, when a key in en.json is read by nothing, and when another language declares a key English does not have. All three are silent failures otherwise: a missing key renders as the key, dead copy gets faithfully translated, and a typo in a translation file does nothing at all while looking done.

Star on GitHub

Bookmark it with a

Need help?