How to Add Internationalization (i18n) to Your Starter Kit (2026)
·StarterPick Team
i18ninternationalizationnextjsguide2026
TL;DR
Adding i18n to a Next.js boilerplate takes 2-5 days with next-intl. The work involves: routing setup, translation file structure, component updates, and date/number formatting. Makerkit and Supastarter include i18n out of the box — save the 5 days if internationalization is critical to your product.
Package Choice: next-intl vs react-i18next
For Next.js App Router, next-intl is the recommended choice in 2026:
| next-intl | react-i18next | |
|---|---|---|
| App Router support | Native | Manual integration |
| Server Components | ✅ | Partial |
| Type safety | ✅ TypeScript types | Manual |
| Bundle impact | Minimal | Moderate |
| Complexity | Low | Medium |
npm install next-intl
Step 1: Routing Setup
// middleware.ts — locale-based routing
import createMiddleware from 'next-intl/middleware';
export default createMiddleware({
locales: ['en', 'es', 'fr', 'de', 'ja'],
defaultLocale: 'en',
localePrefix: 'as-needed', // /en/dashboard stays /dashboard, others get prefix
});
export const config = {
matcher: ['/((?!api|_next|.*\\..*).*)'],
};
// i18n.ts — request-level configuration
import { getRequestConfig } from 'next-intl/server';
export default getRequestConfig(async ({ locale }) => ({
messages: (await import(`./messages/${locale}.json`)).default,
}));
Step 2: Translation Files
messages/
├── en.json
├── es.json
├── fr.json
└── de.json
// messages/en.json
{
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"loading": "Loading...",
"error": "An error occurred"
},
"auth": {
"signIn": "Sign In",
"signOut": "Sign Out",
"signUp": "Sign Up",
"forgotPassword": "Forgot password?",
"email": "Email address",
"password": "Password",
"errors": {
"invalidCredentials": "Invalid email or password",
"emailTaken": "This email is already registered"
}
},
"billing": {
"plan": "Plan",
"currentPlan": "Current plan",
"upgrade": "Upgrade",
"cancel": "Cancel subscription",
"manageBilling": "Manage billing",
"monthly": "per month",
"annual": "per year",
"trial": "{days} day free trial"
},
"dashboard": {
"welcome": "Welcome back, {name}!",
"newProject": "New Project",
"projects": "Projects"
}
}
// messages/es.json
{
"common": {
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"loading": "Cargando...",
"error": "Ocurrió un error"
},
"auth": {
"signIn": "Iniciar sesión",
"signOut": "Cerrar sesión"
},
"billing": {
"trial": "Prueba gratuita de {days} días"
}
}
Step 3: Using Translations in Components
// Server Component
import { useTranslations } from 'next-intl';
export default function DashboardHeader({ userName }: { userName: string }) {
const t = useTranslations('dashboard');
return (
<header>
<h1>{t('welcome', { name: userName })}</h1>
<button>{t('newProject')}</button>
</header>
);
}
// Client Component
'use client';
import { useTranslations } from 'next-intl';
export function SaveButton() {
const t = useTranslations('common');
return <button type="submit">{t('save')}</button>;
}
Step 4: Type-Safe Translations
// Create type definitions for autocomplete
// types/i18n.d.ts
import en from '../messages/en.json';
type Messages = typeof en;
declare interface IntlMessages extends Messages {}
Now TypeScript will catch missing translation keys:
t('common.nonExistentKey') // TypeScript error! Key doesn't exist
t('billing.trial', { days: 14 }) // Works, properly typed
Step 5: Locale Switcher Component
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, usePathname } from 'next/navigation';
const SUPPORTED_LOCALES = [
{ code: 'en', name: 'English' },
{ code: 'es', name: 'Español' },
{ code: 'fr', name: 'Français' },
{ code: 'de', name: 'Deutsch' },
];
export function LocaleSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const handleChange = (newLocale: string) => {
// Remove current locale prefix and replace with new one
const pathWithoutLocale = pathname.replace(`/${locale}`, '') || '/';
router.push(`/${newLocale}${pathWithoutLocale}`);
};
return (
<select
value={locale}
onChange={(e) => handleChange(e.target.value)}
className="border rounded px-2 py-1"
>
{SUPPORTED_LOCALES.map(({ code, name }) => (
<option key={code} value={code}>{name}</option>
))}
</select>
);
}
Step 6: Date and Number Formatting
import { useFormatter } from 'next-intl';
export function SubscriptionEndDate({ date }: { date: Date }) {
const format = useFormatter();
return (
<span>
{/* Auto-formats based on user locale */}
{format.dateTime(date, { dateStyle: 'long' })}
{/* en: March 15, 2026 */}
{/* es: 15 de marzo de 2026 */}
{/* de: 15. März 2026 */}
</span>
);
}
export function PriceDisplay({ amount }: { amount: number }) {
const format = useFormatter();
return (
<span>
{format.number(amount, { style: 'currency', currency: 'USD' })}
{/* en: $29.00 */}
{/* de: 29,00 $ */}
{/* ja: $29.00 */}
</span>
);
}
Time Budget
| Step | Duration |
|---|---|
| Package setup + routing | 0.5 day |
| Translation file structure | 0.5 day |
| Component updates (wrap strings) | 1-2 days |
| Locale switcher + user preference | 0.5 day |
| Date/number formatting | 0.5 day |
| Testing + edge cases | 0.5 day |
| Total | 3-4 days |
For boilerplates targeting global markets: Makerkit and Supastarter include i18n with 6+ languages pre-configured.
Find boilerplates with i18n built-in on StarterPick.
Check out this boilerplate
View Makerkit on StarterPick →