How to Add i18n to Your Starter Kit 2026
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.
When to Add i18n (and When Not To)
The business case for i18n depends entirely on your target market. English-only SaaS products can reach 1.5 billion potential customers — a large enough market for most micro-SaaS products. Adding i18n before product-market fit is premature optimization: you're maintaining multiple language files while still figuring out whether your core value proposition is correct.
The right time to add i18n: when you have paying customers in a non-English market who are churning or complaining about language, or when enterprise sales require localization as a procurement requirement. Japan, Germany, France, and Spain are the markets most likely to demand localization for B2B SaaS products.
The cost of i18n is ongoing. Every string change requires a translation. Every new feature needs translation work before it ships in localized markets. If you're a solo developer, this means either hiring translators (ongoing cost) or using machine translation (quality tradeoff). Build this cost into your decision.
If you're building from scratch and know internationalization is required, choose Makerkit or Supastarter which ship with i18n pre-configured for 6+ languages. The setup time savings are significant.
When i18n Affects Your Database
Translation files handle UI strings. User-generated content — blog posts, project names, documents — requires a different approach. You have two options:
Single-language content: Require users to create content in one language. The simplest approach. If your database stores a title field, it stores one title in whatever language the user typed it. This is appropriate for most SaaS products where users manage their own content.
Multi-language content: Store multiple translations of the same content in the database. Used by content management systems, e-commerce platforms, and multi-regional SaaS products. The schema approach:
model BlogPost {
id String @id @default(cuid())
slug String @unique
translations BlogPostTranslation[]
}
model BlogPostTranslation {
id String @id @default(cuid())
postId String
locale String // 'en', 'es', 'de'
title String
content String @db.Text
post BlogPost @relation(fields: [postId], references: [id], onDelete: Cascade)
@@unique([postId, locale])
}
// Fetch the translation for the current locale, fallback to English
export async function getPost(slug: string, locale: string) {
const post = await prisma.blogPost.findUnique({
where: { slug },
include: {
translations: {
where: { locale: { in: [locale, 'en'] } },
},
},
});
// Use requested locale if available, fallback to English
const translation =
post?.translations.find(t => t.locale === locale) ??
post?.translations.find(t => t.locale === 'en');
return translation ? { ...post, ...translation } : null;
}
Most SaaS products don't need multi-language content in the database. The UI translation approach (next-intl) covers the product interface; the content is whatever the user typed. Add database-level multi-language support only if the product itself is a content management or publishing tool.
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
Testing Translations
Internationalized applications have a class of bugs that only appear in specific locales: missing translation keys, layout breaks from longer text, date format mismatches. Testing strategies:
Missing key detection: next-intl throws a warning (configurable to error) when a translation key is missing in a locale. In CI, configure it to throw errors rather than fall back silently:
// i18n.ts — fail on missing keys in production
export default getRequestConfig(async ({ locale }) => {
let messages;
try {
messages = (await import(`./messages/${locale}.json`)).default;
} catch {
// Fallback to English if locale file is missing
messages = (await import('./messages/en.json')).default;
}
return {
messages,
onError(error) {
if (process.env.NODE_ENV === 'production') {
// Log to monitoring service — don't show raw key to users
console.error('[i18n error]', error);
}
},
getMessageFallback({ key, namespace }) {
// In development: show the key path so you can find missing translations
return process.env.NODE_ENV === 'development'
? `[MISSING: ${namespace}.${key}]`
: key.split('.').at(-1) ?? key; // Show the key name as fallback
}
};
});
Visual regression for RTL: Right-to-left layouts often break in subtle ways — icons on the wrong side, truncated text, misaligned form labels. Add Playwright or Cypress screenshots for key pages in Arabic (ar) locale to catch RTL layout regressions before they ship.
Long string testing: German strings average 30% longer than English. A button labeled "Save" (4 chars) becomes "Speichern" (9 chars) in German. Test your UI in German to identify layouts that overflow with longer strings. In Tailwind, truncate and min-w-0 are your tools for preventing overflow without wrapping.
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>
);
}
Handling Pluralization and Complex Translations
Simple string translations (Save → Guardar) are straightforward. Pluralization and context-sensitive translations require additional configuration that most guides skip.
Pluralization: "1 user" vs "2 users" — languages handle this differently. German has complex plural rules. Arabic has six plural forms. next-intl handles this via the ICU message format:
// messages/en.json
{
"members": {
"count": "{count, plural, =0 {No members} =1 {1 member} other {# members}}",
"invited": "{count, plural, =1 {Invited {count} person} other {Invited {count} people}}"
}
}
// messages/de.json — German plural rules are different
{
"members": {
"count": "{count, plural, =0 {Keine Mitglieder} =1 {1 Mitglied} other {# Mitglieder}}"
}
}
// Usage
const t = useTranslations('members');
t('count', { count: 5 }) // "5 members" (en) / "5 Mitglieder" (de)
t('count', { count: 1 }) // "1 member" (en) / "1 Mitglied" (de)
Gender-specific translations: Some languages require different words based on grammatical gender. The ICU select format handles this:
{
"notification": {
"invited": "{gender, select, female {She invited you} male {He invited you} other {They invited you}}"
}
}
Dynamic content in translations: Pass variables to translation strings to avoid concatenation that breaks in other languages. Never concatenate translated strings — word order differs between languages:
// ❌ Wrong — word order breaks in other languages
t('plan') + ': ' + planName
// ✅ Correct — variable in translation string
t('currentPlan', { plan: planName })
// en.json: "currentPlan": "Current plan: {plan}"
// ja.json: "currentPlan": "現在のプラン: {plan}" (Japanese word order preserved)
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>
);
}
RTL Language Support
Arabic, Hebrew, Persian, and Urdu read right-to-left. If your internationalization roadmap includes these markets, RTL support requires layout changes beyond translation file additions.
Next.js handles the HTML dir attribute at the root level:
// app/[locale]/layout.tsx
import { notFound } from 'next/navigation';
const RTL_LOCALES = ['ar', 'he', 'fa', 'ur'];
export default function LocaleLayout({
children,
params: { locale },
}: {
children: React.ReactNode;
params: { locale: string };
}) {
return (
<html lang={locale} dir={RTL_LOCALES.includes(locale) ? 'rtl' : 'ltr'}>
<body>{children}</body>
</html>
);
}
Tailwind CSS v4 includes RTL utilities out of the box. The rtl: variant mirrors your layout: rtl:ml-4 becomes mr-4 in RTL context. Audit your layout components for hardcoded left/right positioning that doesn't use logical properties.
Translation Workflow for Teams
Maintaining translation files manually doesn't scale past 2-3 languages. For teams with professional translation requirements, the toolchain:
Locize (SaaS): Import your JSON files, invite translators, export updated translations back. Integrates with next-intl directly. Free for solo projects, $10/month for teams.
Crowdin (self-hosted or SaaS): Used by Mozilla, Microsoft, and many open-source projects. Supports translation memory (reusing past translations for identical strings), glossaries, and machine translation suggestions. GitHub integration auto-creates PRs with updated translation files.
Machine translation review workflow: Generate machine translations via DeepL or Google Translate, then have a native speaker review and correct. 80% of the strings in a SaaS product are UI text (Save, Cancel, Settings, Plan, Dashboard) that translates correctly without human review. Focus human review time on marketing copy, error messages, and nuanced UX text.
For a typical SaaS boilerplate with 500-1,000 strings per language, professional translation costs $0.10-0.15 per word. English UI strings average 3-4 words. Budget $150-600 per language for initial translation, with ongoing costs proportional to new strings per release.
Storing Locale Preference
Users expect their locale preference to persist across sessions. Three approaches:
URL-based: The user's locale is in the URL path (/fr/dashboard). Doesn't require a database field. Works with server-side rendering and caching. The downside: if a French user shares a link, the recipient gets a French URL.
Cookie-based: Set a NEXT_LOCALE cookie on locale switch. next-intl reads this cookie automatically. Persists across sessions without database storage, but doesn't sync across devices.
Database-based: Store locale on the User model. The user's preferred locale follows them on any device. Requires reading from the database on every authenticated request, but the performance cost is negligible when using React's cache().
For B2B SaaS, database-stored locale is the right default — it respects multi-device use and doesn't surprise enterprise users who log in from different machines.
SEO for Internationalized Content
International SEO requires additional configuration beyond translation files. Without it, search engines either miss your localized pages or treat them as duplicate content penalties.
hreflang tags: Tell search engines which URL serves which locale. Next.js with next-intl generates these automatically when you configure alternates correctly in your metadata:
// app/[locale]/blog/[slug]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
const { locale, slug } = params;
// Build hreflang alternate URLs for all supported locales
const locales = ['en', 'es', 'fr', 'de'];
const languages: Record<string, string> = {};
for (const l of locales) {
languages[l] = `https://yoursaas.com/${l}/blog/${slug}`;
}
return {
alternates: {
canonical: `https://yoursaas.com/${locale}/blog/${slug}`,
languages,
},
};
}
This outputs <link rel="alternate" hreflang="es" href="https://yoursaas.com/es/blog/slug" /> tags in the page <head>, which tells Google to show the Spanish version to Spanish-language searchers.
Locale-specific sitemaps: Your sitemap should include entries for all locale variants of each URL. Extend the sitemap configuration:
// app/sitemap.ts — multi-locale sitemap
const LOCALES = ['en', 'es', 'fr', 'de'];
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getPosts();
const urls: MetadataRoute.Sitemap = [];
for (const locale of LOCALES) {
// Add locale-specific blog posts
for (const post of posts) {
urls.push({
url: `https://yoursaas.com/${locale}/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: 'weekly',
priority: locale === 'en' ? 0.8 : 0.6, // Prioritize default locale
});
}
}
return urls;
}
Content language consistency: Machine-translated content ranks poorly if it looks like machine translation. Google penalizes thin or low-quality translated content. For the pages where SEO matters most (landing page, pricing, top blog posts), invest in professional translation. For secondary pages and long-tail content, machine translation with human review is acceptable.
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.
Related Resources
For boilerplates that ship with i18n pre-configured including locale routing and translation files, best boilerplates for white-label SaaS covers the multi-locale, multi-tenant patterns needed for white-label deployments. For SEO implications of localized content — hreflang tags, locale-specific sitemaps — SEO in SaaS boilerplates covers the international SEO configuration. For dark mode support alongside locale-specific layouts, how to add dark mode to any SaaS boilerplate covers the CSS variable approach that composes well with RTL layouts.
Methodology
Implementation patterns based on next-intl v3 documentation and the official Next.js internationalization guide. RTL patterns from Tailwind CSS v4 documentation. Translation cost estimates from Locize's published pricing and professional translation market rates. As AI-assisted translation tools have matured through 2025, the cost of maintaining a multi-locale codebase has dropped significantly — machine translation with human review now achieves quality previously requiring full professional translation for many product UI strings.
Find boilerplates with i18n built-in on StarterPick.
Check out this boilerplate
View Makerkiton StarterPick →