Skip to main content

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-intlreact-i18next
App Router supportNativeManual integration
Server ComponentsPartial
Type safety✅ TypeScript typesManual
Bundle impactMinimalModerate
ComplexityLowMedium
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

StepDuration
Package setup + routing0.5 day
Translation file structure0.5 day
Component updates (wrap strings)1-2 days
Locale switcher + user preference0.5 day
Date/number formatting0.5 day
Testing + edge cases0.5 day
Total3-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 →

Comments