Internationalization
Two locale strategies on next-intl. URL prefix for marketing and auth, NEXT_LOCALE cookie for the dashboard. Package layout, message catalogs, switchers, User.locale for emails, and how to add a locale.
Last updated on
6 min readOn This Page
Two Locale Strategies
SyntaxKit uses next-intl with two strategies:
| Surface | Locale source | URL shape |
|---|---|---|
| Marketing and auth | URL prefix | /en/pricing, /de/auth/login |
| Dashboard | NEXT_LOCALE cookie | /dashboard/... (no locale in the path) |
apps/web/proxy.ts decides which strategy applies for each request.
URL prefix for marketing. Search engines need a stable URL per language. Shares should open in that language. /en/pricing and /de/pricing are different pages in the index. The URL is the source of truth.
Cookie for the dashboard. A shared link like /dashboard/billing/invoices/inv_123 must work for every teammate, regardless of language. The path stays fixed. Locale follows the user via the cookie.
Package Layout
i18n spans three places:
packages/i18n: shared registry and path helpers (both apps import these)apps/web/i18n: next-intl wiring for the product appapps/web/messages: translation catalogs
Locale registry
Two locales today (en, de). Default is en. Both apps import this registry:
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",
};@syntaxkit/i18n helpers fall into two groups:
- Locale checks:
isValidLocale,normalizeLocale - Path builders:
withLocalePrefix,getLoginPath,getSignupPath, and the other auth/onboarding helpers
proxy.ts uses the path builders when redirecting legacy unprefixed entry points (e.g. /auth/login → /<locale>/auth/login).
How A Request Routes
apps/web/proxy.ts has five branches. Each picks 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 |
Dashboard requests skip next-intl middleware on purpose. The URL does not carry locale there; request.ts reads the cookie at render time. If next-intl ran on those routes, it would try to inject /en/... prefixes into paths that must stay unprefixed.
Translating Content
Pick a namespace (a top-level key in the JSON), get a translator, call it with keys. Same pattern on client and server.
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 at apps/web/messages/<locale>.json. Namespaces are nested top-level keys (Header, Auth.login, Settings.language, and so on). apps/web/global.d.ts types messages from en.json, so missing keys in other locales fail the type check 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. That keeps marketing pages statically generated. useFormatter is locale-aware and forces dynamic rendering.
If you need locale-aware formatting (German 15.08.2026 instead of 8/15/2026) and accept the SSG hit, switch call sites to useFormatter (client) or getFormatter (server).
Switching Locales
Both switchers call the same setLocale server action and write the same NEXT_LOCALE cookie. They differ only in what happens to the URL.
- Marketing (
locale-switcher.tsxin the header). Writes the cookie, thenrouter.replaceto the same path under the new/<locale>/prefix. The cookie carries the choice into the dashboard after sign-in. - Dashboard (
user-locale-form.tsxin personal settings). Writes the cookie, thenrouter.refresh(). The URL stays at/dashboard/personal-settings.
Pick German on the marketing header, sign in, and the dashboard opens in German. The cookie was already set.
The NEXT_LOCALE cookie
This cookie powers the dashboard strategy. setLocale in apps/web/i18n/actions.ts writes it with:
| Attribute | Value |
|---|---|
| Path | / |
| Max age | 1 year |
| SameSite | lax |
| HttpOnly | not set (read by client and server) |
| Domain | not set (defaults to current host) |
Three consumers read it:
| Consumer | Role |
|---|---|
request.ts | Locale at render for dashboard pages and any route without a [locale] segment. |
proxy.ts (getPreferredLocale) | Locale for redirect URLs (e.g. unauthenticated /dashboard → /<cookie-locale>/auth/login). |
Auth readLocaleCookieFromHeaders | Locale from a Better Auth callback Request so transactional emails match the current preference. |
The cookie is the live preference. Logged-in users also persist it to User.locale for surfaces with no request.
User.locale fallback
Some work has no request cookie: welcome emails from a Better Auth DB hook, Stripe webhooks, invitations to people without a session. For those, signed-in users store locale in a nullable User.locale column.
| Concern | Behavior |
|---|---|
| Written | New signups: afterUserCreate reads NEXT_LOCALE from the signup request. Logged-in switches: setLocale updates the user row and the cookie when a session exists. |
| Read | Transactional email uses resolveAuthEmailLocale: request cookie, then User.locale, then defaultLocale. |
| Why nullable | null means no explicit preference. Fall back to cookie or default. Users who never switch (and admin-created users) do not get a language forced in. |
See Email → Localization for the full resolution flow.
Adding A New Locale
Register the locale
Add the code (e.g. "fr") to locales in packages/i18n/src/config.ts and add a label in localeLabels. The Locale type updates automatically because locales is as const.
Create the message file
Copy apps/web/messages/en.json to apps/web/messages/fr.json and translate every key. Do not omit keys. TypeScript requires them via Messages: typeof messages in apps/web/global.d.ts.
Verify the type check
Run pnpm --filter @syntaxkit/web check-types. Missing keys show up as type errors with the key path. Fix until clean.
Test the marketing path
Visit /<new-locale>/ in dev. next-intl should route the new locale; the URL prefix and content should both update. The marketing LocaleSwitcher picks up the option from localeLabels automatically.
Test the dashboard path
In personal settings, select the new locale and save. The cookie writes, the page refreshes, and the dashboard re-renders in that language. The URL stays at /dashboard/personal-settings.
Where To Go Next
Authentication
Auth-flow paths use the builders from packages/i18n; login picks locale from the URL or cookie depending on where it renders.
How resolveAuthEmailLocale picks cookie, User.locale, then defaultLocale for transactional mail.
Also useful: API, Conventions, and Setup.
React Email templates and Plunk delivery in packages/email. Rendering stays provider-agnostic; sendEmail branches on EMAIL_DELIVERY_MODE (log, noop, or plunk). Dev defaults to a local HTML outbox so you never need a provider account.
Customization
Retheme with OKLCH tokens in one CSS file, swap fonts and dark mode, extend shadcn primitives in packages/ui, and drive homepage branding from brand and marketing config.
