Back to Blog

Next.js Internationalization: next-intl, Locale Routing, Pluralization, and SEO

Implement Next.js internationalization with next-intl: locale-based routing with App Router, server-side translations in RSC, pluralization, date/number formatting, hreflang SEO, and locale detection middleware.

Viprasol Tech Team
December 18, 2026
13 min read

Internationalizing a Next.js App Router application is more involved than the old Pages Router approach — there's no built-in i18n routing in App Router, but next-intl fills that gap cleanly. It handles locale-based URL routing (/en/about, /de/uber-uns), server-side translations in React Server Components without prop drilling, pluralization, and locale-aware date/number formatting.

This post covers the full setup: directory structure, middleware for locale detection and routing, translations in RSC and Client Components, pluralization rules, locale-aware formatting, and hreflang tags for SEO.

1. Project Structure

src/
  app/
    [locale]/           ← All pages inside locale segment
      layout.tsx
      page.tsx
      about/
        page.tsx
      blog/
        [slug]/
          page.tsx
  i18n/
    request.ts          ← next-intl server config
    routing.ts          ← locale definitions
  messages/
    en.json
    de.json
    fr.json
    es.json
  middleware.ts

2. Installation and Configuration

npm install next-intl
// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';

export const routing = defineRouting({
  locales: ['en', 'de', 'fr', 'es'],
  defaultLocale: 'en',

  // Locale prefix strategy
  localePrefix: 'as-needed',  // /en/about vs /about (default locale no prefix)
  // Alternatives:
  // 'always'  → /en/about, /de/uber-uns (explicit for all locales)
  // 'never'   → /about, /about (use Accept-Language header only — harder for SEO)

  // Optional: custom locale paths (slug translation)
  pathnames: {
    '/about': {
      en: '/about',
      de: '/uber-uns',
      fr: '/a-propos',
      es: '/acerca-de',
    },
    '/blog/[slug]': {
      en: '/blog/[slug]',
      de: '/blog/[slug]',
      fr: '/blog/[slug]',
      es: '/blog/[slug]',
    },
  },
});
// src/i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';

export default getRequestConfig(async ({ requestLocale }) => {
  let locale = await requestLocale;

  // Validate locale — fall back to default if invalid
  if (!locale || !routing.locales.includes(locale as any)) {
    locale = routing.defaultLocale;
  }

  return {
    locale,
    messages: (await import(`../../messages/${locale}.json`)).default,
    // Time zone: use user preference or server default
    timeZone: 'UTC',
    // Number formatting defaults
    formats: {
      number: {
        currency: { style: 'currency', minimumFractionDigits: 2 },
      },
    },
  };
});

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript — production-grade stack
  • Fixed-price contracts — no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

3. Middleware for Locale Routing

// middleware.ts (project root)
import createMiddleware from 'next-intl/middleware';
import { routing } from './src/i18n/routing';
import { type NextRequest, NextResponse } from 'next/server';

const intlMiddleware = createMiddleware(routing);

export default function middleware(req: NextRequest): NextResponse {
  // Run next-intl middleware for locale routing
  return intlMiddleware(req);
}

export const config = {
  // Match all pathnames except static files and Next.js internals
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico|images|fonts).*)'],
};

4. Translation Messages

// messages/en.json
{
  "common": {
    "loading": "Loading...",
    "error": "Something went wrong",
    "tryAgain": "Try again",
    "save": "Save changes",
    "cancel": "Cancel",
    "delete": "Delete",
    "confirm": "Confirm"
  },
  "nav": {
    "home": "Home",
    "about": "About",
    "blog": "Blog",
    "pricing": "Pricing",
    "contact": "Contact us"
  },
  "pricing": {
    "title": "Simple, transparent pricing",
    "subtitle": "Choose the plan that works for your team",
    "perMonth": "per month",
    "startTrial": "Start free trial",
    "plans": {
      "starter": {
        "name": "Starter",
        "description": "Perfect for small teams getting started"
      },
      "pro": {
        "name": "Pro",
        "description": "For growing teams that need more power"
      }
    },
    "seats": "{count, plural, one {# seat} other {# seats}}",
    "trialDays": "{days, plural, one {# day free trial} other {# days free trial}}"
  },
  "blog": {
    "readMore": "Read more",
    "publishedOn": "Published on {date}",
    "minuteRead": "{minutes, plural, one {# minute read} other {# minute read}}",
    "relatedPosts": "Related posts"
  },
  "errors": {
    "notFound": "Page not found",
    "notFoundDescription": "The page you're looking for doesn't exist.",
    "serverError": "Server error",
    "serverErrorDescription": "Something went wrong on our end. Please try again."
  }
}
// messages/de.json
{
  "common": {
    "loading": "Wird geladen...",
    "error": "Etwas ist schiefgelaufen",
    "tryAgain": "Erneut versuchen",
    "save": "Änderungen speichern",
    "cancel": "Abbrechen",
    "delete": "Löschen",
    "confirm": "Bestätigen"
  },
  "nav": {
    "home": "Startseite",
    "about": "Über uns",
    "blog": "Blog",
    "pricing": "Preise",
    "contact": "Kontakt"
  },
  "pricing": {
    "title": "Einfache, transparente Preise",
    "subtitle": "Wählen Sie den Plan, der zu Ihrem Team passt",
    "perMonth": "pro Monat",
    "startTrial": "Kostenlos testen",
    "seats": "{count, plural, one {# Platz} other {# Plätze}}",
    "trialDays": "{days, plural, one {# Tag kostenlos} other {# Tage kostenlos}}"
  },
  "blog": {
    "readMore": "Weiterlesen",
    "publishedOn": "Veröffentlicht am {date}",
    "minuteRead": "{minutes, plural, one {# Minute Lesezeit} other {# Minuten Lesezeit}}",
    "relatedPosts": "Verwandte Beiträge"
  },
  "errors": {
    "notFound": "Seite nicht gefunden",
    "notFoundDescription": "Die gesuchte Seite existiert nicht.",
    "serverError": "Serverfehler",
    "serverErrorDescription": "Auf unserer Seite ist etwas schiefgelaufen. Bitte versuchen Sie es erneut."
  }
}

🚀 Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

5. Using Translations in Server Components

// src/app/[locale]/pricing/page.tsx
import { useTranslations } from 'next-intl';
import { getTranslations, getLocale } from 'next-intl/server';
import { setRequestLocale } from 'next-intl/server';
import type { Metadata } from 'next';

// Generate metadata with locale-aware title/description
export async function generateMetadata({
  params,
}: {
  params: Promise<{ locale: string }>;
}): Promise<Metadata> {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'pricing' });

  return {
    title: t('title'),
    description: t('subtitle'),
    alternates: {
      // hreflang tags for SEO
      languages: {
        en: '/en/pricing',
        de: '/de/preise',
        fr: '/fr/tarifs',
        es: '/es/precios',
        'x-default': '/pricing',
      },
    },
  };
}

// Server Component — useTranslations works server-side with next-intl
export default async function PricingPage({
  params,
}: {
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  // Required in Next.js 15+ for static rendering with i18n
  setRequestLocale(locale);

  const t = useTranslations('pricing');

  return (
    <div className="mx-auto max-w-4xl px-4 py-16">
      <h1 className="text-4xl font-bold text-center text-gray-900">{t('title')}</h1>
      <p className="mt-4 text-xl text-center text-gray-500">{t('subtitle')}</p>

      <div className="mt-12 grid grid-cols-2 gap-8">
        <PlanCard
          name={t('plans.starter.name')}
          description={t('plans.starter.description')}
          price={29}
          seats={5}
          trialDays={14}
        />
        <PlanCard
          name={t('plans.pro.name')}
          description={t('plans.pro.description')}
          price={99}
          seats={25}
          trialDays={14}
        />
      </div>
    </div>
  );
}

function PlanCard({
  name, description, price, seats, trialDays,
}: {
  name: string; description: string; price: number; seats: number; trialDays: number;
}) {
  const t = useTranslations('pricing');
  const locale = useLocale();

  return (
    <div className="rounded-xl border border-gray-200 p-6">
      <h2 className="text-xl font-semibold">{name}</h2>
      <p className="mt-1 text-gray-500 text-sm">{description}</p>
      <p className="mt-4 text-3xl font-bold">
        {/* Locale-aware number formatting */}
        {new Intl.NumberFormat(locale, { style: 'currency', currency: 'USD' }).format(price)}
        <span className="text-base font-normal text-gray-400"> {t('perMonth')}</span>
      </p>
      {/* ICU message format pluralization */}
      <p className="mt-2 text-sm text-gray-600">{t('seats', { count: seats })}</p>
      <p className="text-sm text-gray-600">{t('trialDays', { days: trialDays })}</p>
      <button className="mt-6 w-full rounded-lg bg-blue-600 py-2 text-sm font-medium text-white">
        {t('startTrial')}
      </button>
    </div>
  );
}

6. Translations in Client Components

// src/components/LocaleSwitcher.tsx
'use client';

import { useLocale, useTranslations } from 'next-intl';
import { useRouter, usePathname } from 'next-intl/client';
import { routing } from '../../i18n/routing';

export function LocaleSwitcher() {
  const locale = useLocale();
  const router = useRouter();
  const pathname = usePathname();

  const localeNames: Record<string, string> = {
    en: 'English',
    de: 'Deutsch',
    fr: 'Français',
    es: 'Español',
  };

  function handleChange(newLocale: string) {
    // Switches locale while keeping the current pathname
    router.replace(pathname, { locale: newLocale });
  }

  return (
    <select
      value={locale}
      onChange={(e) => handleChange(e.target.value)}
      className="rounded border border-gray-300 px-2 py-1 text-sm"
      aria-label="Select language"
    >
      {routing.locales.map((loc) => (
        <option key={loc} value={loc}>
          {localeNames[loc] ?? loc}
        </option>
      ))}
    </select>
  );
}

// Client component using translations
'use client';
import { useTranslations, useFormatter } from 'next-intl';

export function BlogCard({ post }: { post: Post }) {
  const t = useTranslations('blog');
  const format = useFormatter();

  return (
    <article className="rounded-lg border p-4">
      <h3 className="font-semibold">{post.title}</h3>
      <p className="text-sm text-gray-500">
        {t('publishedOn', {
          date: format.dateTime(new Date(post.publishedAt), {
            year: 'numeric', month: 'long', day: 'numeric',
          }),
        })}
      </p>
      <p className="text-xs text-gray-400">
        {t('minuteRead', { minutes: post.readTime })}
      </p>
    </article>
  );
}

7. Layout with Locale Provider

// src/app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, setRequestLocale } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing } from '../../i18n/routing';

export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;

  // Validate locale
  if (!routing.locales.includes(locale as any)) {
    notFound();
  }

  setRequestLocale(locale);

  // Pass messages to Client Components
  const messages = await getMessages();

  return (
    <html lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}>
      <body>
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

Cost Reference

Translation approachCostScale
JSON files in repo$0< 1,000 keys
Crowdin / Phrase$50–200/moTeam translation workflow
DeepL Auto-translate$5.49/mo + $25/M charsAI-assisted first pass
Locize (next-intl native)$10–100/moReal-time translation updates

See Also


Working With Viprasol

Adding multiple languages to an existing Next.js App Router application, or starting a new project that needs to serve English, German, French, and Spanish from day one? We implement next-intl with locale-based routing, server-side RSC translations, ICU pluralization, locale-aware formatting, and hreflang SEO — so your app is globally ready without a full rewrite.

Talk to our team → | See our web development services →

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms — we build it all with Next.js and React.

Free consultation • No commitment • Response within 24 hours

Viprasol · Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.