Skip to content
Build With SyntaxKit

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 read

On This Page

Two Locale Strategies

SyntaxKit uses next-intl with two strategies:

SurfaceLocale sourceURL shape
Marketing and authURL prefix/en/pricing, /de/auth/login
DashboardNEXT_LOCALE cookie/dashboard/... (no locale in the path)

apps/web/proxy.ts decides which strategy applies for each request.

proxy.ts routes incoming requests by path. Marketing routes resolve locale from the URL prefix and pass through next-intl middleware; dashboard routes resolve locale from the NEXT_LOCALE cookie and skip the middleware.

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 app
  • apps/web/messages: translation catalogs
config.tsLocales, default, labels, isValidLocale
paths.tsnormalizeLocale, withLocalePrefix, and the auth/onboarding path builders
index.tsPublic re-exports
routing.tsdefineRouting with localePrefix: always
request.tsgetRequestConfig: URL-vs-cookie resolution
navigation.tsLocale-aware Link / redirect / usePathname / useRouter
actions.tssetLocale server action that writes the NEXT_LOCALE cookie

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 patternAuth gateLocale sourceNotes
/api-referenceYes (cookie)Cookie (for login redirect URL)Skips next-intl middleware
/api/*, /rpc/*, /trpc/*NoNoneCORS only
/dashboard/*Yes (cookie)NEXT_LOCALE cookie at renderSkips next-intl middleware; URL stays clean
/auth/*, /accept-invitation/*, /create-organizationNoCookie (for redirect target locale)307 redirects to /<locale>/<path>
Everything elseNoURL prefix via next-intl middlewareMarketing 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.tsx in the header). Writes the cookie, then router.replace to the same path under the new /<locale>/ prefix. The cookie carries the choice into the dashboard after sign-in.
  • Dashboard (user-locale-form.tsx in personal settings). Writes the cookie, then router.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.

This cookie powers the dashboard strategy. setLocale in apps/web/i18n/actions.ts writes it with:

AttributeValue
Path/
Max age1 year
SameSitelax
HttpOnlynot set (read by client and server)
Domainnot set (defaults to current host)

Three consumers read it:

ConsumerRole
request.tsLocale 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 readLocaleCookieFromHeadersLocale 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.

ConcernBehavior
WrittenNew signups: afterUserCreate reads NEXT_LOCALE from the signup request. Logged-in switches: setLocale updates the user row and the cookie when a session exists.
ReadTransactional email uses resolveAuthEmailLocale: request cookie, then User.locale, then defaultLocale.
Why nullablenull 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

Also useful: API, Conventions, and Setup.

Was this page helpful?

On this page