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
  helpers/
    render-rich.tsx       → renderLink, renderStrong, renderSmall, renderBr for t.rich()

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.

Rich text rendering

src/core/i18n/helpers/render-rich.tsx provides helpers for t.rich() — use them to insert JSX elements (links, strong, small, line breaks) inside translation strings without raw HTML.

tsx
import { useTranslations } from 'next-intl';
import {
  renderLink,
  renderStrong,
  renderSmall,
  renderBr,
} from '@/core/i18n/helpers/render-rich';

const t = useTranslations('pages.landing');

t.rich('description', {
  br: renderBr(),
  strong: renderStrong(),
  link: renderLink({ href: '/pricing', className: 'text-primary' }),
  small: renderSmall('text-muted-foreground'),
});

Available helpers:

  • renderLink(props) — renders a next/link with the given props
  • renderStrong(className?) — renders <strong> with font-bold + optional class
  • renderSmall(className?) — renders <small> with the given class
  • renderBr() — renders <br>, returns a stable reference