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:
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 localelocalePrefix: 'always'— no locale negotiation, explicit prefix
File structure
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/ structureMessages
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>.
// 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:
// 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
- Create
messages/<locale>/mirroring thefr/structure - Add the locale to
supportedLocalesinrouting.ts generateStaticParamsinlayout.tsxpicks it up automatically viarouting.locales
Middleware
withIntl runs first in the proxy chain (src/proxy.ts):
/api/*and/trpc/*routes: setsx-localeheader fromx-localeheader →NEXT_LOCALEcookie →routing.defaultLocale- Other routes: delegates to
next-intl/middlewarefor 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
import { getTranslations } from 'next-intl/server';
export default async function Page() {
const t = await getTranslations('pages.landing');
return <h1>{t('title')}</h1>;
}Client components
import { useTranslations } from 'next-intl';
export function MyComponent() {
const t = useTranslations('components.themeToggle');
return <button aria-label={t('toggleDark')} />;
}Navigation
Use the locale-aware helpers from @/core/i18n/navigation instead of next/link and next/navigation:
import { Link, useRouter, usePathname } from '@/core/i18n/navigation';
<Link href="/about">About</Link>;
const router = useRouter();
router.push('/dashboard', { locale: 'fr' });Metadata
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.
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.