Internationalization
Locale-aware routing and translated content.
Last updated on
7 min readSyntaxKit's i18n is built on next-intl, but it doesn't use one routing strategy. It uses two, deliberately. Public marketing and auth pages live under /[locale]/... with the locale in the URL for SEO and shareability; the dashboard stays at /dashboard/... and gets its locale from a NEXT_LOCALE cookie so deep links don't depend on the user's language. apps/web/proxy.ts is the foundation that makes both strategies coexist cleanly.
Two locale strategies
URL-prefix for marketing, cookie for the dashboard, and why.
Translating content
Namespaces, useTranslations, getTranslations, and typed messages.
Switching locales
The two switchers and the shared setLocale server action.
Add a new locale
Register it, translate the catalog, and verify both routes.
Two Locale Strategies
Why URL-prefix for marketing. Search engines need stable canonical URLs per language, social shares need to land in the right language, and /en/pricing and /de/pricing are two different things in Google's index. The URL is the source of truth.
Why cookie for dashboard. A user shares /dashboard/billing/invoices/inv_123 with a teammate; the teammate shouldn't get a 404 because their preference is German and the link said /en/.... URLs stay stable, locale follows the user.
The diagram source lives at apps/docs/diagrams/locale-routing.mmd. Rerun pnpm --filter @syntaxkit/docs diagrams:build after editing it to refresh both SVG variants.
Package Layout
The i18n logic lives across three locations on purpose. packages/i18n owns the registry and path helpers so both apps can share them. apps/web/i18n owns the next-intl wiring specific to the product app. apps/web/messages is the translation catalog.
Locale Registry
Two locales today (en, de), default en. The registry is a small file that both apps import:
export const locales = ["en", "de"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";
export const localeLabels: Record<Locale, string> = {
en: "English",
de: "Deutsch",
};Helpers exported from @syntaxkit/i18n split into two groups: locale validation (isValidLocale, normalizeLocale) and path builders (withLocalePrefix, getLoginPath, getSignupPath, getForgotPasswordPath, getResetPasswordPath, getVerifyEmailPath, getTwoFactorVerifyPath, getAcceptInvitationPath, getCreateOrganizationPath). The path builders are what proxy.ts uses to build redirect URLs for legacy unprefixed entry points like /auth/login → /<locale>/auth/login.
How A Request Routes
Five branches in apps/web/proxy.ts, each handling locale differently:
| Path pattern | Auth gate | Locale source | Notes |
|---|---|---|---|
/api-reference | Yes (cookie) | Cookie (for login redirect URL) | Skips next-intl middleware |
/api/*, /rpc/*, /trpc/* | No | None | CORS only |
/dashboard/* | Yes (cookie) | NEXT_LOCALE cookie at render | Skips next-intl middleware; URL stays clean |
/auth/*, /accept-invitation/*, /create-organization | No | Cookie (for redirect target locale) | 307 redirects to /<locale>/<path> |
| Everything else | No | URL prefix via next-intl middleware | Marketing pages, fully locale-prefixed |
The dashboard branch deliberately skips next-intl's middleware because the URL has nothing to do with the locale there; the locale comes from the cookie at render time inside request.ts. If next-intl ran on dashboard requests it would try to inject /en/... prefixes into routes it shouldn't touch.
Translating Content
Both client and server components translate the same way: pick a namespace (top-level key in the JSON), get the translator, call it with keys.
Client components use useTranslations:
"use client";
import { useTranslations } from "next-intl";
export function HeaderActions() {
const t = useTranslations("Header");
return <button>{t("signIn")}</button>;
}Server components use getTranslations:
import { getTranslations } from "next-intl/server";
export default async function NotFound() {
const t = await getTranslations("errors.notFound");
return <h1>{t("title")}</h1>;
}Messages live in a single JSON per locale at apps/web/messages/<locale>.json. Namespaces are nested top-level keys (Header, Auth.login, Settings.language, etc.). The apps/web/global.d.ts declaration tells TypeScript to use en.json as the canonical message shape, so missing keys in any other locale surface as type errors at build time.
Date and number formatting in the kit uses fixed en-US via Intl.NumberFormat, Intl.DateTimeFormat, and toLocaleString(), not useFormatter from next-intl. This is deliberate: useFormatter is locale-aware, which forces dynamic rendering and disables static-site generation on the marketing pages. Hard-coded formatting keeps those pages statically generated.
If your product needs locale-aware formatting (German 15.08.2026 instead of 8/15/2026) and you accept the SSG hit, swap the formatting call sites to useFormatter (client) or getFormatter (server).
Switching Locales
Two switchers, both writing the same cookie through the setLocale server action. The only difference is whether the URL changes.
Marketing switcher
In the marketing header (locale-switcher.tsx). Persists the choice, then navigates to the same path under the new /<locale>/ prefix via router.replace. The cookie write is what carries the language into the dashboard after sign-in.
Dashboard switcher
In personal settings (user-locale-form.tsx). Calls the same action, then router.refresh() to re-render in the new language. The URL stays at /dashboard/personal-settings.
A user who picks German on the marketing header lands in a German dashboard after signing in, because the cookie was set before the redirect.
The NEXT_LOCALE Cookie
Powers the dashboard half of the design. Written by the setLocale server action (apps/web/i18n/actions.ts) with these attributes:
| Attribute | Value |
|---|---|
| Path | / |
| Max age | 1 year |
| SameSite | lax |
| HttpOnly | not set (read by client and server) |
| Domain | not set (defaults to current host) |
It's read in three places:
request.ts
Resolves the locale at render time for dashboard pages and any route without a [locale] URL segment.
proxy.ts (getPreferredLocale)
Picks the locale for redirect URLs (e.g. an unauthenticated /dashboard hit redirects to /<cookie-locale>/auth/login).
auth readLocaleCookieFromHeaders
Parses the cookie out of a Better Auth callback's Request so transactional emails pick up the user's current locale.
The cookie is the source of truth for the current preference; logged-in users also persist it to User.locale for surfaces that have no request.
User.locale: The Out-Of-Band Fallback
The cookie is useless for surfaces with no request: the welcome email fires from a Better Auth DB hook, Stripe webhooks carry no end-user context, and invitations go to people without a session. So signed-in users persist their locale to a nullable User.locale column.
Where it's written
New signups: the afterUserCreate hook reads NEXT_LOCALE from the signup request. Logged-in switches: setLocale writes the user record alongside the cookie whenever a session exists.
Where it's read
Every transactional email resolves locale via resolveAuthEmailLocale: the request cookie first, then User.locale, then defaultLocale.
Why nullable
null means 'no explicit preference: fall back to the cookie or default', so users who never switch (and admin-created users) don't get a misleading language baked in.
See Email → Localization for the full resolution flow.
Adding A New Locale
Register the locale
Add the new code (e.g. "fr") to the locales array in packages/i18n/src/config.ts and add a label to localeLabels. The Locale type narrows automatically because locales is declared as const.
Create the message file
Copy apps/web/messages/en.json to apps/web/messages/fr.json and translate every key. Don't omit keys; TypeScript treats them as required because of the Messages: typeof messages declaration in apps/web/global.d.ts.
Verify the type check
Run pnpm --filter @syntaxkit/web check-types. Any missing key in the new file shows up as a type error pointing at the missing key path. Fix until clean.
Test the marketing path
Visit /<new-locale>/ in dev. The next-intl middleware should now route the new locale; the URL prefix and content should both update. Marketing-side LocaleSwitcher will pick up the new option from localeLabels automatically.
Test the dashboard path
Open personal settings, select the new locale, save. The cookie writes, the page refreshes, and the dashboard re-renders in the new language. URL stays at /dashboard/personal-settings.
Update the docs site (optional)
The docs site reads its locale registry from the same @syntaxkit/i18n package, so the locale appears in the language switcher automatically. Translating the actual MDX content under apps/docs/content/docs/ is a separate workstream; the Fumadocs i18n primitive supports per-locale page files when you're ready.
Where To Go Next
Authentication
The auth-flow paths use the path builders from packages/i18n; the login form picks up the right locale from the URL or cookie depending on where it's rendered.
API
oRPC handlers can read the active locale via getLocale() / getMessages() from next-intl/server when they need to template translated copy (e.g. the contact form notification).
Setup
The env layer doesn't currently gate i18n, but the overall configuration story lives there.
Environment Variables
Where future locale-related toggles (e.g. enabling a new locale via env, or switching default) would land.
