Leave Localhost logoLeave LocalhostDocs
UI

Internationalization (i18n)

Leave Localhost ships with full internationalization support powered by next-international. The product app includes three locales out of the box — English, French, and Spanish — and adding more is straightforward.

Leave Localhost ships with full internationalization support powered by next-international. The product app includes three locales out of the box — English, French, and Spanish — and adding more is straightforward.

Architecture

apps/app/src/
├── locales/
│   ├── config.ts        # locale list, types, and path helpers
│   ├── client.ts        # client-side hooks and provider
│   ├── server.ts        # server-side getI18n / getScopedI18n
│   ├── en.ts            # English translations (default)
│   ├── fr.ts            # French translations
│   └── es.ts            # Spanish translations
├── proxy.ts             # locale detection and URL rewriting (Next 16 convention)
└── app/
    └── [locale]/         # all routes are nested under a locale segment

How Locale Routing Works

The proxy at apps/app/src/proxy.ts uses createI18nMiddleware with the rewrite URL mapping strategy (the file is named proxy.ts because that is the Next.js 16 convention; middleware.ts is the deprecated name):

const I18nMiddleware = createI18nMiddleware({
  locales,
  defaultLocale,
  urlMappingStrategy: "rewrite",
});
  • Rewrite strategy — the default locale (en) is served without a prefix in the URL. Non-default locales are prefixed (e.g. /fr/settings). The [locale] dynamic segment catches both cases.
  • Detection — on first visit, next-international checks the Accept-Language header and sets a cookie. Subsequent visits use the cookie.

Translation Files

Each locale file exports a flat-ish object with nested namespaces:

// locales/en.ts
export default {
  navigation: {
    dashboard: "Dashboard",
    settings: "Settings",
    // ...
  },
  dashboard: {
    workspace: {
      roleLabel: "Your role",
      // ...
    },
  },
  // ...
} as const;

All locale files must share the same shape. TypeScript enforces this — if you add a key to en.ts, the other locale files will produce type errors until you add the corresponding translations.

Namespaces

Translations are organized by feature area:

NamespaceContent
navigationSidebar labels, page titles, user menu items
dashboardWorkspace info card, records table, record dialog
organizationWorkspace switcher, create/rename/delete workspace
membersMember list, invitations, invite dialog
settingsAccount settings, avatar, username, delete account
securitySensitive action verification dialog
billingPlan display, checkout, subscription management
loginSign-in page, magic link, social auth, password auth
verify2faTwo-factor verification challenge
onboardingUsername setup flow
acceptInvitationInvitation acceptance flow
errorsGeneric error boundary messages

Using Translations

In Client Components

"use client";

import { useI18n, useScopedI18n } from "@/locales/client";

function MyComponent() {
  // Full namespace access
  const t = useI18n();
  t("navigation.dashboard"); // "Dashboard"

  // Scoped to a namespace for cleaner calls
  const tNav = useScopedI18n("navigation");
  tNav("dashboard"); // "Dashboard"
}

In Server Components

import { getI18n, getScopedI18n } from "@/locales/server";

export default async function Page() {
  const t = await getI18n();
  const tNav = await getScopedI18n("navigation");

  return <h1>{tNav("dashboard")}</h1>;
}

String Interpolation

Some translations include placeholders wrapped in {braces}:

// en.ts
readyTitle: "Join {workspace}",

Pass parameters as the second argument:

t("acceptInvitation.readyTitle", { workspace: "Acme Team" });
// → "Join Acme Team"

Getting the Current Locale

import { useCurrentLocale } from "@/locales/client";

const locale = useCurrentLocale(); // "en" | "fr" | "es"

Changing the Locale

import { useChangeLocale } from "@/locales/client";

const changeLocale = useChangeLocale();
changeLocale("fr"); // navigates to the French version

The Language Switcher

The built-in language switcher lives in the user dropdown menu in the sidebar footer. It is implemented in apps/app/src/app/[locale]/(dashboard)/_components/language-switcher.tsx as a DropdownMenuSub with radio items for each locale.

Adding a language to the switcher requires adding it to the langs array in that file:

const langs = [
  { text: "English", value: "en" },
  { text: "French", value: "fr" },
  { text: "Spanish", value: "es" },
  { text: "German", value: "de" }, // ← new
];

Adding a New Locale

  1. Create the translation file — duplicate en.ts as your new locale file (e.g. de.ts) and translate all strings.

  2. Register the locale in three places:

    // locales/config.ts
    export const locales = ["en", "fr", "es", "de"] as const;
    // locales/client.ts
    export const { useI18n, ... } = createI18nClient({
      en: () => import("./en"),
      fr: () => import("./fr"),
      es: () => import("./es"),
      de: () => import("./de"), // ← new
    });
    // locales/server.ts
    export const { getI18n, ... } = createI18nServer({
      en: () => import("./en"),
      es: () => import("./es"),
      fr: () => import("./fr"),
      de: () => import("./de"), // ← new
    });
  3. Add to the language switcher — update the langs array in language-switcher.tsx as shown above.

  4. Typecheck — run bun run typecheck to verify the new file matches the expected shape.

Locale-Aware Route Helpers

The getAppPathname() function in locales/config.ts strips the locale segment from a pathname so that route matching works against locale-agnostic paths:

getAppPathname("/fr/settings"); // → "/settings"
getAppPathname("/settings");    // → "/settings"

This is used by the sidebar and site header to determine the active navigation item regardless of the current locale prefix.

I18n Provider Placement

The I18nProviderClient wraps the dashboard layout, not the entire app. This is intentional — public routes (login, invitation acceptance) that do not use the dashboard layout still get i18n through the proxy's locale detection and server-side getI18n.

// apps/app/src/app/[locale]/(dashboard)/layout.tsx
<I18nProviderClient locale={locale}>
  {/* dashboard content */}
</I18nProviderClient>

Next Reads

On this page