Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 068 / 100

Internationalization in Next.js: lessons from production

Multi-language sites with the App Router.

Expona Admin8 min read

Internationalization in Next.js

Building for a global audience means more than just translating strings—you need a solid foundation for locale routing, message management, and SEO awareness. Internationalization in Next.js with the App Router gives you the tooling to handle multi-language sites elegantly, letting you serve different content to different regions while keeping your codebase maintainable. I've shipped regional platforms across Sri Lanka, India, and Europe, and I'll walk you through the pattern that actually scales.

Locale in the URL — /en/ and /ta/ segments are clear for users and search engines

The cleanest approach is putting your locale directly in the URL path. Instead of query strings or subdomain tricks, you structure your routes as /en/about, /ta/about, /fr/contact. This approach wins because:

  • Search engines treat each locale as a separate canonical URL, improving indexing.
  • Users and analytics tools can instantly see which language version they're viewing.
  • Your server-side logic and middleware can extract the locale early in the request lifecycle.

With Next.js App Router, you achieve this using dynamic segments and middleware. Here's the folder structure:

tsx
app/
  [locale]/
    page.tsx
    layout.tsx
    about/
      page.tsx
    contact/
      page.tsx
middleware.ts

Your middleware intercepts all requests and ensures a valid locale is present:

tsx
import { NextRequest, NextResponse } from 'next/server';

const locales = ['en', 'ta', 'fr'];
const defaultLocale = 'en';

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;
  const pathnameHasLocale = locales.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  if (pathnameHasLocale) {
    return NextResponse.next();
  }

  // Redirect to default locale
  return NextResponse.redirect(
    new URL(`/${defaultLocale}${pathname}`, request.url)
  );
}

export const config = {
  matcher: ['/((?!api|_next|favicon).*)'],
};

Inside your layout or page components, you extract the locale from params:

tsx
// app/[locale]/layout.tsx
import { ReactNode } from 'react';

interface Props {
  children: ReactNode;
  params: { locale: string };
}

export default function LocaleLayout({ children, params }: Props) {
  return (
    
      {children}
    
  );
}

This pattern ensures every page gets the locale parameter, and you can reference it in your components to load the right messages and format data correctly.

next-intl — Messages, formatting and routing helpers that work with Server Components

Rather than building message loading and formatting from scratch, the next-intl library integrates cleanly with App Router's Server Components. It handles locale detection, message loading, and provides hooks for use in Client Components too.

First, set up your messages structure. I typically keep messages in JSON files per locale:

tsx
// messages/en.json
{
  "home": {
    "title": "Welcome",
    "description": "Explore our platform"
  },
  "nav": {
    "about": "About",
    "contact": "Contact"
  }
}

// messages/ta.json
{
  "home": {
    "title": "வரவேற்கிறோம்",
    "description": "எங்கள் தளத்தை ஆராயுங்கள்"
  },
  "nav": {
    "about": "பற்றி",
    "contact": "தொடர்புக்கு"
  }
}

Configure next-intl in your layout to wrap your app and provide locale context:

tsx
// app/[locale]/layout.tsx
import { notFound } from 'next/navigation';
import { NextIntlClientProvider } from 'next-intl';
import en from '@/messages/en.json';
import ta from '@/messages/ta.json';

const messages = { en, ta };

interface Props {
  children: React.ReactNode;
  params: { locale: string };
}

export default function LocaleLayout({ children, params }: Props) {
  const locale = params.locale;

  if (!messages[locale as keyof typeof messages]) {
    notFound();
  }

  return (
    
      
        
          {children}
        
      
    
  );
}

Now in your components, use the useTranslations hook to access messages:

tsx
// app/[locale]/page.tsx
'use client';

import { useTranslations } from 'next-intl';

export default function HomePage() {
  const t = useTranslations();

  return (
    <div>
      <h1>{t('home.title')}</h1>
      <p>{t('home.description')}</p>
    </div>
  );
}

For Server Components, use the getTranslations function directly:

tsx
// app/[locale]/about/page.tsx
import { getTranslations } from 'next-intl/server';

export default async function AboutPage() {
  const t = await getTranslations();

  return (
    <div>
      <h1>{t('nav.about')}</h1>
    </div>
  );
}

The benefit of next-intl is that it centralizes locale handling. You don't scatter locale checks throughout your codebase; you declare which locales you support once, and the library ensures consistency.

Format with Intl — Numbers, currencies and dates with Intl APIs — never hand-rolled

Localizing strings is only half the battle. Dates, numbers, and currencies need locale-aware formatting. The browser's native Intl API handles this beautifully, and you should lean on it rather than writing custom logic.

Never write formatting like this:

tsx
// ❌ Don't do this
const price = 1234.56;
const formatted = '$' + price.toFixed(2); // Works in US, breaks elsewhere

Instead, use the Intl API. Here's a utility file I keep in every project:

tsx
// lib/intl.ts
export function formatCurrency(amount: number, locale: string, currency: string = 'USD'): string {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: currency,
  }).format(amount);
}

export function formatNumber(value: number, locale: string): string {
  return new Intl.NumberFormat(locale).format(value);
}

export function formatDate(date: Date, locale: string, options?: Intl.DateTimeFormatOptions): string {
  return new Intl.DateTimeFormat(locale, options).format(date);
}

export function formatDateTime(date: Date, locale: string): string {
  return new Intl.DateTimeFormat(locale, {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    hour: '2-digit',
    minute: '2-digit',
  }).format(date);
}

In your components, use these helpers with the locale from your context:

tsx
// app/[locale]/products/page.tsx
'use client';

import { useLocale } from 'next-intl';
import { formatCurrency, formatDate } from '@/lib/intl';

interface Product {
  id: string;
  name: string;
  price: number;
  createdAt: Date;
}

export default function ProductsPage({ products }: { products: Product[] }) {
  const locale = useLocale();

  return (
    <div>
      {products.map((product) => (
        <div>
          <h3>{product.name}</h3>
          <p>{formatCurrency(product.price, locale, 'USD')}</p>
          <small>{formatDate(new Date(product.createdAt), locale)}</small>
        </div>
      ))}
    </div>
  );
}

When you use Intl, the formatting respects the user's locale automatically. A user in India sees "₹1,234.56" for 1234.56 USD converted to INR, while a user in France sees "1 234,56 €". You write the code once and it works everywhere.

hreflang tags — Tell search engines about every language version

Search engines need to know that your pages exist in multiple languages. If you don't declare this explicitly, they might show the wrong version in search results. The hreflang tag tells them, "This page exists in English at /en/about, in Tamil at /ta/about, and in French at /fr/about."

Add hreflang tags in your layout's head:

tsx
// app/[locale]/layout.tsx
import { ReactNode } from 'react';

const locales = ['en', 'ta', 'fr'];
const baseUrl = 'https://example.com';

interface Props {
  children: ReactNode;
  params: { locale: string };
}

export default function LocaleLayout({ children, params }: Props) {
  const locale = params.locale;
  const path = '/'; // Adjust based on pathname if needed

  return (
    
      
        {/* Self-referencing hreflang */}
        
        {/* Alternate language versions */}
        {locales.map((l) => (
          l !== locale && (
            
          )
        ))}
        {/* Default/fallback version */}
        
      
      {children}
    
  );
}

For a more dynamic approach that changes based on the current route, create a helper component:

tsx
// components/HrefLangTags.tsx
interface Props {
  pathname: string;
  locales: string[];
  baseUrl: string;
}

export function HrefLangTags({ pathname, locales, baseUrl }: Props) {
  return (
    <>
      {locales.map((locale) => (
        
      ))}
      
    
  );
}

Use it in your layout, computing the pathname from the request:

tsx
// app/[locale]/layout.tsx
import { HrefLangTags } from '@/components/HrefLangTags';

export default function LocaleLayout({ children, params }: Props) {
  const pathname = '/' + params.locale + '/about'; // Construct or extract

  return (
    
      
        
      
      {children}
    
  );
}

Search engines crawl these tags and understand that your site serves multiple languages, which improves indexing and reduces duplicate content penalties.

Common mistakes

  • Hardcoding locales: Don't sprinkle locale strings like "en" and "ta" throughout your codebase. Define them once in a config file and import everywhere.
  • Skipping hreflang: Some developers assume search engines will figure out language versions automatically. They won't. Add hreflang tags from day one.
  • Building formatters from scratch: Rolling your own date or currency formatting for multiple locales is error-prone. Use Intl APIs; they're battle-tested and handle edge cases you haven't thought of.
  • Not testing with actual users: Translation tools sometimes miss context. A phrase that makes sense in English might sound odd when translated. Have native speakers review key pages.
  • Forgetting to set lang attribute: Always set the lang attribute on your <html> tag. Screen readers and search engines use it.

Key takeaways

  • Put locale in your URL path (/en/, /ta/) for clarity and SEO; use middleware to enforce it across your app.
  • Use next-intl to centralize message loading and provide context to all components, reducing boilerplate.
  • Leverage the native Intl API for formatting numbers, currencies, and dates—never hand-roll these.
  • Add hreflang tags to every page so search engines understand your language versions and serve the correct one in results.
  • Test your i18n setup with real native speakers and monitor regional analytics to catch translation or localization issues early.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles