Internationalization
Paraglide messages, English at / and Chinese at /zh, how locale is detected and remembered, localized emails and content, and adding a language.
ShipKit ships in English and Chinese. Translation uses Paraglide JS, which compiles every message into a small typed function, so a missing key is a type error and a page only downloads the strings it uses.
Messages
All UI copy lives in two flat JSON files:
messages/en.json
messages/zh.json
Keys are flat and prefixed by area — auth_login_title, admin_emails_title, email_welcome_subject, setting_account_retention. Parameters use braces:
{
"email_welcome_subject": "Welcome to {app}"
}
In a component, import m and call the key:
import { m } from '@/paraglide/messages'
<h1>{m.auth_login_title()}</h1>
<p>{m.email_welcome_subject({ app: appConfig.name })}</p>
The compiled output in src/paraglide/ is generated by the Paraglide Vite plugin whenever the dev server or a build runs, using the options in vite.config.ts. Do not run paraglide-js compile by hand: it ignores those options (cookie name, URL strategy) and locale routing breaks until the dev server restarts. The project settings — base locale and the list of locales — are in project.inlang/settings.json.
Why the prefixes matter
The prefixes are also how the bundle is split. vite.config.ts groups messages by prefix into chunks: admin_* loads with the admin console, and marketing_, blog_, docs_, home_, footer_, seo_ and legal_ load with the public site. A visitor to the landing page does not download the console's copy. If you add an area with a lot of text, give its keys a prefix and consider adding a group there.
URLs
English is unprefixed and Chinese is under /zh:
| English | Chinese |
|---|---|
/pricing | /zh/pricing |
/dashboard | /zh/dashboard |
/ | /zh |
The route tree has no locale segment. The router (src/router.tsx) strips the prefix from incoming URLs and adds it back to outgoing ones, so route files are written once and a <Link to="/pricing"> points at /zh/pricing when the reader is in Chinese. For an href built outside the router, use localizeHref() from @/paraglide/runtime.
How the locale is chosen
The Paraglide strategy is url, then cookie, then the browser's preferred language, then the base locale (en). Because an unprefixed URL always means English, the other signals are applied by a small script at the top of <head> in src/routes/__root.tsx: on an unprefixed page it redirects to /zh/... if the APP_LOCALE cookie says zh, or, on a first visit with no cookie, if the browser's language is Chinese. This runs in the browser because marketing pages are prerendered and served as static files, where no server code can look at the request.
On the server, src/server.ts wraps every request in paraglideMiddleware, so getLocale() resolves correctly during server rendering.
Switching language
The language menu in the marketing header and the language row in Settings both call setLocale(), which writes the APP_LOCALE cookie and reloads the page in the new language. The Settings row also saves the choice to the user's locale column, which is what emails are sent in.
SEO
Every page emits a self-referencing canonical plus hreflang alternates for each locale and an x-default (in __root.tsx). The sitemap generator (scripts/generate-sitemap.ts) lists each English page with its /zh twin.
Server functions never localize
A server function is called at /_serverFn/..., a URL with no locale segment. Paraglide inside one resolves from the cookie or Accept-Language, not from the page the caller is looking at — so a visitor who reached /zh by a link would get English strings back. The rule is:
- Server code returns keys and values (an error code, an amount, a status).
- The component calls
m.*to turn them into text.
Account deletion is a real example. A feature guard on the server refuses with a code (blockAccountDeletion('ACTIVE_SUBSCRIPTION')), and the settings component maps it to copy:
switch (code) {
case 'ACTIVE_SUBSCRIPTION':
toast.error(m.settings_danger_active_subscription())
break
default:
toast.error(m.auth_error_generic())
}
The registries the admin console renders, such as settings and permissions, keep their labels as functions (label: () => m.setting_account_retention()) and are read in the browser for the same reason. The landing page config in src/config/landing.ts does the same: every string is a thunk, because the module is evaluated once per Worker isolate while the locale is per request.
The exception is text that leaves the app — emails and notifications. Those are localized explicitly by the recipient's user.locale, never by the request:
m.email_welcome_subject({ app }, { locale })
user.locale is set at sign-up from the language cookie and changed in Settings. See Email and notifications. In-app notifications store only a type and parameters and are rendered in the viewer's current language when the bell opens.
Localized content
Blog posts, docs and legal pages are MDX files, one per locale: privacy.en.mdx and privacy.zh.mdx, or getting-started.mdx with a getting-started.zh.mdx beside it. A missing translation falls back to the locale-neutral file, then to English, so a page never disappears from a list for lack of a translation. See Content.
Adding a language
Adding a third locale is more than a JSON file, because some places know the two current locales by name. Using German (de) as the example:
- Add
"de"tolocalesinproject.inlang/settings.json. - Create
messages/de.jsonwith every key frommessages/en.json. - In
vite.config.ts, add['de', '/de/:path(.*)?']tourlPatternsbefore theenentry, and add{ path: '/de/' }to the prerenderpages. - Add the language's display name to
localeNamesinsrc/components/LocaleSwitcher.tsxand insrc/features/auth/components/account-section.tsx. - Update the places that hard-code
zh:- the redirect script and
og:localeinsrc/routes/__root.tsx scripts/generate-sitemap.tssrc/config/private-paths.ts, whose/zhvariants keep private pages out of the prerender androbots.txtsrc/core/email/templates/receipt.ts, for number and date formatsstripeLocale()insrc/core/payment/stripe.ts, for Stripe's checkout language- the
'en' | 'zh'type insrc/features/billing/server/events.ts
- the redirect script and
- Add
.de.mdxfiles for the legal pages and any docs or posts you translate. - Extend the tests that loop over
['en', 'zh'](src/core/settings/i18n.test.ts,src/core/email/templates/templates.test.ts).
Then run bun run typecheck and bun run test, and click through /de with bun run dev.