Internationalization
Locale-aware routing and translated content.
SyntaxKit's i18n is built on next-intl with Next.js next/root-params. Marketing and auth pages use locale-prefixed URLs (/en/..., /de/...) for SEO and shareability. The dashboard stays at unprefixed /dashboard/... — the proxy rewrites those requests into app/[locale]/dashboard using the NEXT_LOCALE cookie — so deep links don't depend on the user's language, while marketing and dashboard still share one root layout (soft navigation between them).
One root, two URL shapes
Locale-prefixed marketing plus cookie-rewritten dashboard under a single [locale] layout.
Translating content
Namespaces, useTranslations, getTranslations, and typed messages.
Switching locales
Client-side cookie sync, immediate navigation or refresh, and ordered background persistence.
Add a new locale
Register it, translate the catalog, and verify both routes.
One Root Layout, Two URL Shapes
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 unprefixed dashboard URLs. 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 via the cookie rewrite.
Why one root layout. Both trees live under app/[locale]/. Locale is read from the URL segment via next/root-params in i18n/request.ts. Crossing from home to dashboard is a soft client navigation because they share <html>/<body> and providers. Direct hits to /{locale}/dashboard/* are redirected to /dashboard/* so there is exactly one canonical dashboard URL.
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
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 |
/{locale}/dashboard/* | — | — | 307 to unprefixed /dashboard/* (canonical URL) |
/dashboard/* | Yes (cookie) | NEXT_LOCALE cookie → rewrite to /{locale}/dashboard/* | Skips next-intl middleware; URL stays clean |
/create-organization, /{locale}/create-organization | Yes (cookie) | URL prefix if present, else cookie (for login redirect URL) | Unauthenticated → 307 to /<locale>/auth/login; authenticated bare path → 307 to /<locale>/create-organization |
/auth/*, /accept-invitation/* | 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: the public URL has no locale prefix. The proxy rewrites into the [locale] segment so next/root-params resolves the locale at render time. If next-intl ran on dashboard requests it would try to inject /en/... prefixes into routes it shouldn't touch.
Outside the [locale] tree (config-error, top-level 404), request.ts falls back to the cookie then defaultLocale. That cookie read is gated so it never taints static rendering of marketing/auth routes.
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 share one cookie configuration and one per-tab persistence queue. Neither interactive path calls the cookie-writing setLocale server action.
Marketing switcher
In the marketing header (locale-switcher.tsx). Navigates immediately to the same path under the new /<locale>/ prefix. next-intl synchronizes NEXT_LOCALE client-side before router.replace, so the prefetched route stays warm.
Dashboard switcher
In personal settings (user-locale-form.tsx). Writes NEXT_LOCALE with the shared client helper, then immediately refreshes inside a transition. The URL stays at /dashboard/personal-settings; the refreshed request is rewritten to the new locale segment.
Both paths enqueue persistLocalePreference without awaiting it. The shared queue keeps at most one cookie-free persistence action in flight, remembers only the latest locale requested while it runs, and starts that latest write after the current database attempt settles. Navigation and refresh therefore never wait for User.locale, while rapid same-tab switches cannot commit out of order.
setLocale remains available for server-side callers that need the action to own the cookie write. Interactive switches avoid it deliberately: mutating cookies in a Server Action forces Next.js to re-render the current route and invalidate the entire client prefetch cache before the intended navigation or refresh can start.
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 rewrite. next-intl writes it client-side during locale-aware marketing navigation; the dashboard uses setClientLocaleCookie before refreshing. Both read the same configuration in apps/web/i18n/locale-cookie.ts, and the server-side setLocale variant writes matching 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
Fallback when outside the [locale] tree (config-error, top-level 404). Inside [locale], locale comes from next/root-params.
proxy.ts (getPreferredLocale)
Picks the locale for the /dashboard rewrite target and 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: afterUserCreate reads NEXT_LOCALE from the signup request. Logged-in interactive switches: the per-tab queue serializes cookie-free User.locale writes. Server-side setLocale callers persist after the response.
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 and select the new locale. The client cookie writes, the page refreshes immediately, and the proxy rewrites /dashboard into the new locale segment while User.locale persistence runs in the background. The 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.