How to Make Your Next.js Website Multilingual with next-intl in 2025

A complete, production-ready next-intl setup for Next.js 15 with routing, middleware, messages, and layouts

·Matija Žiberna·
How to Make Your Next.js Website Multilingual with next-intl in 2025

I just finished making my blog multilingual, and let me tell you, it was quite the journey. What started as a simple "let's add German and Slovenian support" turned into a deep dive through Next.js 15's App Router, middleware configurations, and some unexpected routing challenges that had me scratching my head at 2 AM.

After working through all the gotchas and finding solutions that actually work in production, I wanted to document the complete process. By the end of this guide, you'll know how to take any Next.js 15+ website and make it fully multilingual with next-intl.

The Foundation: Setting Up next-intl

The first step is getting next-intl properly configured. This library has become the go-to solution for Next.js internationalization, and for good reason - it works seamlessly with the App Router and provides excellent TypeScript support.

npm install next-intl

The core of any next-intl setup is the routing configuration. This defines your supported languages and custom URL paths for each locale.

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

export const routing = defineRouting({
  locales: ['en', 'de', 'sl'],
  defaultLocale: 'en',
  pathnames: {
    '/': '/',
    '/about': {
      en: '/about',
      de: '/uber-uns',
      sl: '/o-nas'
    },
    '/contact': {
      en: '/contact',
      de: '/kontakt',
      sl: '/kontakt'
    },
    '/services': {
      en: '/services',
      de: '/dienstleistungen',
      sl: '/storitve'
    }
  }
});

export type Pathnames = keyof typeof routing.pathnames;
export type Locale = (typeof routing.locales)[number];

This configuration does two important things. First, it defines which languages your site supports. Second, it allows you to customize URLs for each language - notice how /about becomes /uber-uns in German and /o-nas in Slovenian. This is crucial for SEO and user experience in different markets.

Next, you need navigation helpers that work with your routing configuration:

// File: src/i18n/navigation.ts
import {createNavigation} from 'next-intl/navigation';
import {routing} from './routing';

export const {Link, redirect, usePathname, useRouter} =
  createNavigation(routing);

These helpers ensure that all your internal navigation respects the internationalization setup. When you use Link from this file instead of Next.js's default Link, it automatically handles locale prefixes and custom pathnames.

Middleware: The Traffic Controller

The middleware is where the magic happens - it intercepts every request and routes it to the correct localized version of your site. This was actually one of the trickier parts to get right.

// File: src/middleware.ts
import createMiddleware from 'next-intl/middleware';
import {routing} from './i18n/routing';

export default createMiddleware(routing);

export const config = {
  // Exclude certain routes from internationalization
  matcher: '/((?!api|trpc|_next|_vercel|studio|blog|.*\\..*).*)'
};

The matcher pattern is crucial here. It tells Next.js which routes should go through the internationalization middleware and which should be left alone. In my case, I wanted to keep my blog, API routes, and admin studio in English only, so I excluded them from the matcher.

This flexibility is one of the great things about next-intl - you can choose exactly which parts of your application need internationalization support.

Next.js Configuration Updates

You need to integrate next-intl with Next.js's build process:

// File: next.config.ts
import withNextIntl from 'next-intl/plugin';

const nextConfig = withNextIntl('./src/i18n/request.ts')({
  // Your existing Next.js config
  experimental: {
    useCache: true,
  },
  // ... other config options
});

module.exports = nextConfig;

The plugin configuration points to a request configuration file that handles how messages are loaded and processed. This is where you'll define the logic for loading the right translations for each request.

Message Organization: Keeping Translations Manageable

One thing I learned quickly is that throwing all your translations into a single file becomes unwieldy fast. Instead, I organized messages by page and feature:

messages/
├── en/
│   ├── common.json
│   ├── navigation.json
│   ├── homepage.json
│   ├── about.json
│   └── contact.json
├── de/
│   ├── common.json
│   ├── navigation.json
│   ├── homepage.json
│   ├── about.json
│   └── contact.json
└── sl/
    ├── common.json
    ├── navigation.json
    ├── homepage.json
    ├── about.json
    └── contact.json

The common.json file contains shared elements like buttons and form labels:

// File: messages/en/common.json
{
  "buttons": {
    "submit": "Submit",
    "send": "Send",
    "cancel": "Cancel",
    "readMore": "Read More",
    "contactMe": "Contact Me"
  },
  "form": {
    "name": "Name",
    "email": "Email",
    "message": "Message",
    "required": "This field is required",
    "invalidEmail": "Please enter a valid email"
  }
}

Page-specific files contain content unique to those pages:

// File: messages/en/homepage.json
{
  "hero": {
    "title": "Full Stack Developer",
    "subtitle": "Building Modern Web Solutions",
    "description": "I specialize in Next.js, TypeScript, and creating exceptional digital experiences.",
    "cta": "View My Work"
  },
  "meta": {
    "title": "Build with Matija | Full Stack Developer",
    "description": "Full Stack Developer specializing in Next.js and TypeScript development."
  }
}

This modular approach makes translations much easier to manage and allows different team members to work on different sections without conflicts.

Request Configuration: Loading the Right Messages

The request configuration ties everything together by loading the appropriate messages for each request:

// File: src/i18n/request.ts
import {getRequestConfig} from 'next-intl/server';
import {hasLocale} from 'next-intl';
import {routing} from './routing';

export default getRequestConfig(async ({requestLocale}) => {
  const requested = await requestLocale;
  const locale = hasLocale(routing.locales, requested)
    ? requested
    : routing.defaultLocale;

  // Import all message files and merge them
  const messages = {
    ...(await import(`../../messages/${locale}/common.json`)).default,
    navigation: (await import(`../../messages/${locale}/navigation.json`)).default,
    homepage: (await import(`../../messages/${locale}/homepage.json`)).default,
    about: (await import(`../../messages/${locale}/about.json`)).default,
    contact: (await import(`../../messages/${locale}/contact.json`)).default,
  };

  return {
    locale,
    messages
  };
});

This configuration loads all the relevant message files for the requested locale and merges them into a single messages object. The locale validation ensures that if someone requests an unsupported language, they get the default locale instead of an error.

Directory Structure: Organizing Your Internationalized Pages

With Next.js App Router, you need to restructure your pages to support dynamic locale routing. The key is creating a [locale] directory that contains all your internationalized pages:

src/app/
├── [locale]/
│   ├── layout.tsx
│   ├── page.tsx          # Homepage
│   ├── about/
│   │   └── page.tsx
│   ├── contact/
│   │   └── page.tsx
│   └── services/
│       └── page.tsx
├── api/                  # Non-internationalized
├── blog/                 # Non-internationalized
├── layout.tsx            # Root layout
└── page.tsx             # Root redirect

The root page.tsx handles the redirect to the default locale:

// File: src/app/page.tsx
import { redirect } from 'next/navigation';
import { routing } from '@/i18n/routing';

export default function RootPage() {
  redirect(`/${routing.defaultLocale}`);
}

Each page in the [locale] directory needs to handle locale parameters and use translations:

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

export async function generateMetadata({params}: {params: Promise<{locale: string}>}) {
  const {locale} = await params;
  const t = await getTranslations({locale, namespace: 'about.meta'});

  return {
    title: t('title'),
    description: t('description')
  };
}

export default async function AboutPage({params}: {params: Promise<{locale: string}>}) {
  const {locale} = await params;
  setRequestLocale(locale);
  
  const t = await getTranslations('about');

  return (
    <div className="container mx-auto px-4 py-12">
      <header className="text-center mb-16">
        <h1 className="text-4xl md:text-5xl font-bold mb-6">{t('hero.title')}</h1>
        <p className="text-xl text-gray-600 dark:text-gray-300">
          {t('hero.description')}
        </p>
      </header>
      {/* Rest of your page content */}
    </div>
  )
}

The setRequestLocale(locale) call is important for static generation, and the getTranslations function provides access to your message files with full TypeScript support.

Layout Configuration: Handling Locales Properly

Your locale-specific layout needs to validate locales and provide the necessary context:

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

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 that the incoming locale parameter is valid
  if (!routing.locales.includes(locale as any)) {
    notFound();
  }

  // Enable static generation
  setRequestLocale(locale);

  return children;
}

This layout validates that the requested locale is supported and sets up static generation for all your defined locales.

Handling Nested Routes and Subpages

One challenge you'll encounter is with nested routes and subpages. If you have URLs like /services/web-development or /projects/portfolio, you need to handle them properly in your routing configuration.

First, make sure you have the nested page files in your directory structure:

src/app/[locale]/
├── services/
│   ├── page.tsx                    # /services main page
│   ├── web-development/
│   │   └── page.tsx                # /services/web-development
│   ├── consulting/
│   │   └── page.tsx                # /services/consulting
│   └── single-purpose-tools/
│       └── page.tsx                # /services/single-purpose-tools

Then, you need to add these nested routes to your routing configuration with localized URLs:

// File: src/i18n/routing.ts
export const routing = defineRouting({
  locales: ['en', 'de', 'sl'],
  defaultLocale: 'en',
  pathnames: {
    '/': '/',
    '/services': {
      en: '/services',
      de: '/dienstleistungen',
      sl: '/storitve'
    },
    // Add nested routes explicitly
    '/services/web-development': {
      en: '/services/web-development',
      de: '/dienstleistungen/web-entwicklung',
      sl: '/storitve/spletni-razvoj'
    },
    '/services/consulting': {
      en: '/services/consulting', 
      de: '/dienstleistungen/beratung',
      sl: '/storitve/svetovanje'
    },
    '/services/single-purpose-tools': {
      en: '/services/single-purpose-tools',
      de: '/dienstleistungen/spezialwerkzeuge',
      sl: '/storitve/specializirana-orodja'
    }
  }
});

Each nested page needs to be a proper Next.js page component with internationalization support:

// File: src/app/[locale]/services/web-development/page.tsx
import { setRequestLocale } from 'next-intl/server';
import { getTranslations } from 'next-intl/server';

export async function generateMetadata({params}: {params: Promise<{locale: string}>}) {
  const {locale} = await params;
  const t = await getTranslations({locale, namespace: 'services.webDev.meta'});

  return {
    title: t('title'),
    description: t('description')
  };
}

export default async function WebDevelopmentPage({params}: {params: Promise<{locale: string}>}) {
  const {locale} = await params;
  setRequestLocale(locale);
  
  const t = await getTranslations('services.webDev');

  return (
    <div className="container mx-auto px-4 py-12">
      <h1 className="text-4xl font-bold mb-6">{t('title')}</h1>
      <p>{t('description')}</p>
      {/* Rest of your page content */}
    </div>
  )
}

Don't forget to add the corresponding message keys to your translation files:

// File: messages/en/services.json
{
  "webDev": {
    "title": "Web Development",
    "description": "Custom web applications built with modern technologies.",
    "meta": {
      "title": "Web Development Services | Build with Matija",
      "description": "Professional web development services using Next.js and TypeScript."
    }
  }
}

The most common mistake is forgetting to create the actual page.tsx files in the nested directories. The routing configuration tells next-intl how to handle the URLs, but you still need the physical page files for the routes to work.

Building a Language Switcher

A good language switcher is essential for user experience. Here's a implementation using modern React patterns:

// File: src/components/layout/LanguageSwitcher.tsx
'use client';

import { usePathname, useRouter } from '@/i18n/navigation';
import { useParams } from 'next/navigation';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Globe } from "lucide-react";

const languages = [
  { code: 'en', name: 'English', flag: '🇺🇸' },
  { code: 'de', name: 'Deutsch', flag: '🇩🇪' },
  { code: 'sl', name: 'Slovenščina', flag: '🇸🇮' }
];

export function LanguageSwitcher() {
  const pathname = usePathname();
  const router = useRouter();
  const params = useParams();

  const currentLocale = params.locale as string;
  const currentLanguage = languages.find(lang => lang.code === currentLocale);

  const handleLanguageChange = (locale: string) => {
    // Ensure we have a clean pathname without any locale prefixes
    let cleanPathname = pathname;
    if (cleanPathname.startsWith(`/${currentLocale}/`)) {
      cleanPathname = cleanPathname.substring(`/${currentLocale}`.length);
    }
    // Remove any other potential locale prefixes
    const locales = ['en', 'de', 'sl'];
    for (const loc of locales) {
      if (cleanPathname.startsWith(`/${loc}/`)) {
        cleanPathname = cleanPathname.substring(`/${loc}`.length);
      }
    }
    router.push(cleanPathname, { locale });
  };

  return (
    <Select value={currentLocale} onValueChange={handleLanguageChange}>
      <SelectTrigger className="w-[140px]">
        <Globe className="h-4 w-4 mr-2" />
        <SelectValue placeholder="Language">
          {currentLanguage?.flag} {currentLanguage?.code.toUpperCase()}
        </SelectValue>
      </SelectTrigger>
      <SelectContent>
        {languages.map((language) => (
          <SelectItem key={language.code} value={language.code}>
            <span className="flex items-center gap-2">
              <span>{language.flag}</span>
              <span>{language.name}</span>
            </span>
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}

The key here is using the navigation helpers from your i18n configuration rather than Next.js's built-in router. This ensures that when users switch languages, they stay on the equivalent page in the new language, and all your custom pathname mappings work correctly.

Smart Language Switcher Visibility

One important user experience consideration is when to show the language switcher. You don't want visitors seeing a language switcher on pages that don't support multiple languages (like /blog or /api routes), only to get 404 errors when they try to switch languages.

The solution is to dynamically detect whether the current route is internationalized by checking it against your routing configuration:

// File: src/components/layout/navigation.tsx
'use client'

import { usePathname } from 'next/navigation'
import { routing } from '@/i18n/routing'
import { LanguageSwitcher } from './LanguageSwitcher'

// Helper function to check if current route is internationalized
function useIsInternationalizedRoute() {
  const pathname = usePathname()
  
  // Check if the current path matches any internationalized route pattern
  return Object.values(routing.pathnames).some((localePaths) => {
    if (typeof localePaths === 'string') {
      // Simple path like '/'
      return pathname === localePaths || pathname.match(new RegExp(`^/(en|de|sl)${localePaths === '/' ? '/?$' : localePaths + '/?$'}`))
    } else {
      // Localized paths object
      return Object.values(localePaths).some(localizedPath => {
        return pathname.match(new RegExp(`^/(en|de|sl)${localizedPath + '/?$'}`))
      })
    }
  })
}

export default function Navigation({ navigationData, blogCategories }: NavigationProps) {
  const showLanguageSwitcher = useIsInternationalizedRoute()
  
  return (
    <nav>
      {/* Your navigation content */}
      <div className="flex items-center space-x-6">
        {showLanguageSwitcher && <LanguageSwitcher />}
        <ThemeToggle />
      </div>
    </nav>
  )
}

This approach automatically shows the language switcher only on routes that are defined in your routing.pathnames configuration. So visitors will see the switcher on:

  • /en/about, /de/uber-uns, /sl/o-nas
  • /en/services/web-development, /de/dienstleistungen/web-entwicklung
  • ✅ All internationalized routes

But it will be hidden on:

  • /blog - no switcher, no confusion
  • /api/* - no switcher
  • ❌ Any non-internationalized routes

This prevents the frustrating user experience where someone clicks a language switcher on a blog page and gets a 404 error. Your routing configuration becomes the single source of truth for both URL mapping and switcher visibility.

Critical Gotcha: Preventing Locale Stacking

One critical issue you'll likely encounter is locale stacking in URLs. For example, if you're on the German resume page (/de/lebenslauf) and switch to Slovenian, you might end up with /de/sl/zivljenjepis instead of the correct /sl/zivljenjepis. This typically happens on the second language switch, not the first.

The problem occurs because usePathname() from next-intl sometimes returns a pathname that still contains locale information after the first navigation, especially in development or certain deployment configurations.

The solution is to clean any locale prefixes from the pathname before navigating:

const handleLanguageChange = (locale: string) => {
  // Special handling for homepage paths (when pathname is just the locale)
  if (pathname === '/' || pathname === `/${currentLocale}` || pathname.match(/^\/(en|de|sl)$/)) {
    // For homepage, navigate to the root path with the new locale
    router.replace('/', { locale });
  } else {
    // For all other routes, use the original logic that works perfectly
    // Ensure we have a clean pathname without any locale prefixes
    let cleanPathname = pathname;
    if (cleanPathname.startsWith(`/${currentLocale}/`)) {
      cleanPathname = cleanPathname.substring(`/${currentLocale}`.length);
    }
    // Remove any other potential locale prefixes
    const locales = ['en', 'de', 'sl'];
    for (const loc of locales) {
      if (cleanPathname.startsWith(`/${loc}/`)) {
        cleanPathname = cleanPathname.substring(`/${loc}`.length);
      }
    }
    router.push(cleanPathname, { locale });
  }
};

This fix handles two different scenarios:

Homepage Routes: When visitors are on homepage paths like /en, /de, or /sl, the switcher uses router.replace('/', { locale }) to prevent the stacking issue that creates URLs like /en/de.

All Other Routes: For regular pages like /en/about/de/uber-uns, it uses the pathname cleaning logic that works perfectly for nested routes.

Without this targeted fix, you'll experience the frustrating URL stacking issue specifically on the homepage that makes language switching unreliable for users.

Integration Challenges and Solutions

During implementation, I ran into several challenges that aren't well-documented elsewhere. The biggest issue was getting the layout structure right - I initially tried a complex nested layout approach that caused 404 errors on certain routes.

The solution was keeping the main layout simple and letting next-intl handle the complexity through middleware and configuration. Your root layout should provide the basic HTML structure and global providers:

// File: src/app/layout.tsx
import { NextIntlClientProvider } from 'next-intl'
import { getMessages } from 'next-intl/server'

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const messages = await getMessages()

  return (
    <html lang="en">
      <body>
        <NextIntlClientProvider messages={messages}>
          <Navigation showLanguageSwitcher={true} />
          <main>
            {children}
          </main>
          <Footer />
        </NextIntlClientProvider>
      </body>
    </html>
  )
}

This approach works reliably and doesn't interfere with Next.js's routing mechanisms.

Testing Your Implementation

Once everything is set up, you need to verify that your internationalization works correctly. Test these key scenarios:

Your default locale redirect should work - visiting your root domain should redirect to /en (or whatever your default locale is). Each localized route should load with the correct translations and metadata. The language switcher should preserve the current page when switching languages.

Build your application and check that static generation works for all locale combinations. Next.js should generate separate static files for each language version of your pages.

The middleware matcher should properly exclude non-internationalized routes. Your API endpoints, admin areas, and other English-only content should remain unaffected by the internationalization setup.

Route Groups: A Flexible Pattern

One pattern that proved especially useful is organizing your routes with groups. You can structure your app directory like this:

src/app/
├── (intl)/
│   └── [locale]/
│       ├── page.tsx
│       ├── about/
│       └── contact/
└── (non-intl)/
    ├── api/
    ├── blog/
    └── admin/

This approach gives you clear separation between internationalized and non-internationalized parts of your application, making the codebase easier to understand and maintain.

Making your Next.js website multilingual with next-intl is definitely more involved than a simple plugin installation, but the results are worth it. You get a robust internationalization system that scales with your application and provides excellent developer experience with TypeScript support throughout.

The key is taking it step by step - get the basic routing working first, then add your message organization, and finally integrate the UI components. Don't try to implement everything at once, and definitely test each piece as you go.

This implementation has been running smoothly in production, handling thousands of visitors across different languages without issues. The modular message structure makes it easy to add new languages or update existing translations, and the middleware configuration gives you complete control over which parts of your site are internationalized.

Let me know in the comments if you have questions about any part of this process, and subscribe for more practical development guides.

Thanks, Matija

4

Comments

Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.
Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in