Skip to content

Internationalization

saaskip uses next-intl for locale-prefixed routing, type-safe messages, and server/client translation access.

Setup

No environment variables required. i18n is always enabled.

Routing

Locales are defined in src/core/i18n/routing.ts:

ts
export const supportedLocales = [
  { code: 'en', name: 'English' },
  { code: 'fr', name: 'Français' },
] as const;

export const routing = defineRouting({
  locales: supportedLocales.map((locale) => locale.code),
  defaultLocale: 'en',
  localePrefix: 'always',
});
  • URLs are always prefixed: /en/about, /fr/about
  • / redirects to the default locale
  • localePrefix: 'always' — no locale negotiation, explicit prefix

File structure

text
src/core/i18n/
  routing.ts              → locale definitions, routing config
  navigation.ts           → Link, redirect, usePathname, useRouter (locale-aware)
  request.ts              → getRequestConfig, dynamic message loading
  middlewares/
    with-intl.ts          → middleware: locale detection for /api, delegation for pages
  components/
    locale-switcher.tsx   → dropdown to switch locale without full reload

messages/
  types.ts                → TranslationSchema<T> for type-safe messages
  en/
    index.ts              → re-exports all namespaces
    labels.ts             → shared UI labels
    components.ts         → component-specific translations
    pages.ts              → page-specific translations
    page-landing.ts       → landing page namespace
  fr/
    ...                   → mirrors en/ structure

Messages

Messages are TypeScript files (not JSON) for type safety. The English locale is the source of truth — other locales are validated against the English schema via satisfies TranslationSchema<typeof en>.

ts
// messages/en/labels.ts
export default {
  back: 'Back',
  home: 'Home',
} as const;

// messages/fr/labels.ts
import type enLabels from '../en/labels';
import type { TranslationSchema } from '../types';

export default {
  back: 'Retour',
  home: 'Accueil',
} as const satisfies TranslationSchema<typeof enLabels>;

Each locale re-exports its namespaces from index.ts:

ts
// messages/en/index.ts
import components from './components';
import labels from './labels';
import pages from './pages';

const en = { components, labels, pages } as const;
export default en;

Adding a new locale

  1. Create messages/<locale>/ mirroring the fr/ structure
  2. Add the locale to supportedLocales in routing.ts
  3. generateStaticParams in layout.tsx picks it up automatically via routing.locales

Middleware

withIntl runs first in the proxy chain (src/proxy.ts):

  • /api/* and /trpc/* routes: sets x-locale header from x-locale header → NEXT_LOCALE cookie → routing.defaultLocale
  • Other routes: delegates to next-intl/middleware for locale detection, prefix injection, and redirects

The x-locale header allows API route handlers to access the resolved locale without re-parsing cookies or headers.

Usage

Server components

ts
import { getTranslations } from 'next-intl/server';

export default async function Page() {
  const t = await getTranslations('pages.landing');
  return <h1>{t('title')}</h1>;
}

Client components

tsx
import { useTranslations } from 'next-intl';

export function MyComponent() {
  const t = useTranslations('components.themeToggle');
  return <button aria-label={t('toggleDark')} />;
}

Use the locale-aware helpers from @/core/i18n/navigation instead of next/link and next/navigation:

tsx
import { Link, useRouter, usePathname } from '@/core/i18n/navigation';

<Link href="/about">About</Link>;

const router = useRouter();
router.push('/dashboard', { locale: 'fr' });

Metadata

ts
export async function generateMetadata({ params }) {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'pages.landing' });
  return { title: `${t('title')} | ${app.title}` };
}

Locale switcher

<LocaleSwitcher /> renders a dropdown that switches locale without full reload — it uses router.push(pathname, { locale }) to preserve the current route.

tsx
import LocaleSwitcher from '@/core/i18n/components/locale-switcher';

<LocaleSwitcher />
<LocaleSwitcher variant="ghost" />

The trigger displays the capitalized current locale code (En, Fr). Each item is labeled with the locale's full name and marked with an active class when selected.