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
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/ 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.
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.
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 anext/linkwith the given propsrenderStrong(className?)— renders<strong>withfont-bold+ optional classrenderSmall(className?)— renders<small>with the given classrenderBr()— renders<br>, returns a stable reference