BuildWithMatija
Get In Touch
  1. Home
  2. Blog
  3. Next.js
  4. next-intl Guide: Add i18n to Next.js 16 (Complete Setup)

next-intl Guide: Add i18n to Next.js 16 (Complete Setup)

Routing, middleware, setRequestLocale, static generation — production-ready v4 setup

5th September 2025·Updated on:28th March 2026·MŽMatija Žiberna·
Next.js
next-intl Guide: Add i18n to Next.js 16 (Complete Setup)

⚡ Next.js Implementation Guides

In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.

No spam. Unsubscribe anytime.

Related Posts:

  • •How to Fix "Couldn't find next-intl config file" Error in Next.js 15

If you've tried to add internationalization to a Next.js 16 app, you know the docs get you 80% of the way there and leave you to figure out the rest. What URL structure works with App Router? Why does static generation break the moment you add translations? What is setRequestLocale and why does every page need it?

I went through all of this building multilingual sites with Payload CMS and Next.js — German, English, Slovenian — and this guide is the complete production-ready setup I wish existed. By the end, you'll have a working next-intl v4 implementation with proper static generation, a language switcher, nested routes, and TypeScript type safety throughout.

Note on versions: This guide targets next-intl v4 and Next.js 16. If you're on next-intl v3, the main difference is the import name — unstable_setRequestLocale was renamed to setRequestLocale in v3.22 and is the stable API going forward.

Installation and Initial Setup

Install next-intl:

npm install next-intl

The core of any next-intl setup is the routing configuration. This is where you define your supported locales and — optionally — localized URL paths per language.

// 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];

The pathnames object lets you localize URLs per language — /about becomes /uber-uns in German and /o-nas in Slovenian. This matters for SEO: localized URLs perform better in local search results and feel more natural to native speakers.

Next, create navigation helpers that wrap Next.js's built-in router with locale awareness:

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

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

Use these helpers throughout your app instead of Next.js's default Link and useRouter. They automatically handle locale prefixes and your custom pathname mappings.

Middleware: The Traffic Controller

In Next.js 16, the middleware file was renamed from middleware.ts to proxy.ts. The next-intl docs explicitly note: "proxy.ts was called middleware.ts up until Next.js 16." The internals haven't changed — createMiddleware from 'next-intl/middleware' is still the function you use — but the filename is different.

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

export default createMiddleware(routing);

export const config = {
  // Match all pathnames except:
  // - paths starting with /api, /trpc, /_next, /_vercel
  // - paths containing a dot (e.g. favicon.ico)
  matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)',
};

Next.js picks up proxy.ts the same way it previously picked up middleware.ts — no additional configuration needed. You pass the routing object from src/i18n/routing.ts directly into createMiddleware, which takes care of locale negotiation, redirects, rewrites, and alternate links for search engines.

Adjust the matcher to exclude any routes in your app that should stay English-only — /blog, admin areas, and similar non-internationalized paths. For proxy-related routing issues after the rename, next-intl with the Next.js 16 proxy middleware covers the common failure patterns.

Next.js Configuration

Wire next-intl into the Next.js build process via the plugin:

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

const nextConfig = withNextIntl('./src/i18n/request.ts')({
  experimental: {
    useCache: true,
  },
  // ... your other config
});

module.exports = nextConfig;

The plugin argument points to your request configuration file, which we'll create next.

Request Configuration: Loading the Right Messages

The request config defines how messages are loaded for each incoming request. This is where locale validation and message merging happens:

// 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;

  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 };
});

hasLocale validates the incoming locale against your supported list. If someone hits an unsupported locale, they fall back to defaultLocale rather than crashing.

Message Organization

Split your translation files by page rather than putting everything in one flat file. This keeps things manageable as your site grows:

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

common.json holds shared UI strings — buttons, form labels, error messages:

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

Page-specific files contain content unique to that page:

// 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."
  }
}

Directory Structure

All internationalized pages live inside a [locale] dynamic segment:

src/app/
├── [locale]/
│   ├── layout.tsx        # Locale layout — validates locale, enables static rendering
│   ├── 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 → /en

The root page.tsx redirects visitors 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}`);
}

Layout Configuration and Static Generation

The [locale]/layout.tsx is where two critical things happen: locale validation and static generation setup.

// File: src/app/[locale]/layout.tsx
import {setRequestLocale} from 'next-intl/server';
import {hasLocale} from 'next-intl';
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;

  if (!hasLocale(routing.locales, locale)) {
    notFound();
  }

  // Enable static rendering for all pages in this layout
  setRequestLocale(locale);

  return children;
}

generateStaticParams tells Next.js which locale variants to pre-render at build time. setRequestLocale makes those static renders work — more on exactly why in the next section.

Understanding setRequestLocale (And Why Every Page Needs It)

This is the part that trips up most developers. setRequestLocale appears in almost every file and the docs don't fully explain why. Here's the complete picture.

The problem it solves

Without setRequestLocale, next-intl reads the current locale from a request header (x-next-intl-locale) that the middleware sets. Reading from headers() is a dynamic API in Next.js — the moment you use it, Next.js opts the entire route into dynamic rendering. That means no static generation, no caching, every request hits the server.

For a multilingual site that should be fully static, this is a problem.

setRequestLocale is the workaround. Instead of reading from headers, it writes the locale into a request-scoped cache (powered by React's cache()) early in the render. All next-intl APIs then read from this cache instead of from headers — so the route stays static.

The next-intl docs describe it explicitly: "By calling setRequestLocale, the current locale will be written to the store, making it available to all APIs that require the locale. The store is scoped to a request and therefore doesn't affect other requests handled in parallel."

Why you need it in both layout AND every page

This is where most implementations break. Next.js can render layouts and pages independently during static generation. If you only call setRequestLocale in the layout, individual pages rendered in isolation won't have the locale in the store and will fall back to the header — breaking static rendering.

The official guidance: "You need to call this function in every page and every layout that you intend to enable static rendering for since Next.js can render layouts and pages independently."

In practice this means every file under [locale]/ that uses next-intl APIs.

The strict ordering rule

setRequestLocale must be called before any other next-intl function in the component. Calling getTranslations or useTranslations before it means those functions try to read from headers first, opting you into dynamic rendering:

// WRONG — getTranslations runs before the locale store is set
export default async function Page({params}) {
  const {locale} = await params;
  const t = await getTranslations('home'); // ❌ reads from headers
  setRequestLocale(locale);
}

// CORRECT — locale store is set first
export default async function Page({params}) {
  const {locale} = await params;
  setRequestLocale(locale);               // ✅ write to store first
  const t = await getTranslations('home'); // reads from store
}

Version note

If you're reading older articles or Stack Overflow answers, you'll see unstable_setRequestLocale. That was renamed to setRequestLocale in next-intl v3.22 and marked stable. The import changed from:

// Before v3.22 — deprecated
import {unstable_setRequestLocale} from 'next-intl/server';

// v3.22+ and v4 — use this
import {setRequestLocale} from 'next-intl/server';

Future of setRequestLocale

The next-intl maintainer acknowledges it's a stopgap. Next.js is working on a rootParams API that would let route params be read anywhere in the component tree without dynamic rendering, which would eliminate the need for setRequestLocale entirely. Until that lands and stabilizes, this is the correct pattern.

Pages: The Full Pattern

With the theory clear, here's what every page under [locale]/ should look like:

// File: src/app/[locale]/about/page.tsx
import {setRequestLocale, 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;

  // Must be first — before any next-intl API call
  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>
    </div>
  );
}

Notice setRequestLocale(locale) is the first thing called in the component body, before getTranslations.

generateMetadata is an exception — it passes locale explicitly to getTranslations as an option, so next-intl doesn't need to read from the store. This is why metadata generation works correctly even without setRequestLocale being called first in that function.

TypeScript Type Safety for Translation Keys

One of next-intl's best features is full TypeScript support for translation keys — autocomplete and type errors when you reference a key that doesn't exist.

To enable it, create a type declaration file that points next-intl to your message shape:

// File: src/types/next-intl.d.ts
import en from '../../messages/en/common.json';

declare global {
  // eslint-disable-next-line @typescript-eslint/no-empty-interface
  interface IntlMessages extends Messages {}
}

type Messages = typeof en;

With this in place, t('buttons.nonExistent') becomes a TypeScript error, and your editor autocompletes translation keys. The Locale type you exported from routing.ts works the same way — use it to type any function that accepts a locale:

import type {Locale} from '@/i18n/routing';

async function getPageData(locale: Locale) {
  // locale is typed as 'en' | 'de' | 'sl' — not just string
}

Handling Nested Routes

For nested URLs like /services/web-development, you need both the directory structure and the routing configuration entry:

src/app/[locale]/
├── services/
│   ├── page.tsx
│   ├── web-development/
│   │   └── page.tsx
│   └── consulting/
│       └── page.tsx

Add each nested route explicitly to routing.ts:

// File: src/i18n/routing.ts
export const routing = defineRouting({
  locales: ['en', 'de', 'sl'],
  defaultLocale: 'en',
  pathnames: {
    '/services': {
      en: '/services',
      de: '/dienstleistungen',
      sl: '/storitve',
    },
    '/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',
    },
  },
});

Each nested page follows the same pattern — setRequestLocale first, then translations:

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

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>
    </div>
  );
}

The most common mistake with nested routes is adding the routing configuration entry but forgetting to create the physical page.tsx file — or vice versa. Both are required.

Building a Language Switcher

The language switcher uses the navigation helpers from your i18n config rather than Next.js's built-in router, so locale-aware routing and custom pathnames work correctly:

// 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) => {
    if (
      pathname === '/' ||
      pathname === `/${currentLocale}` ||
      pathname.match(/^\/(en|de|sl)$/)
    ) {
      router.replace('/', {locale});
    } else {
      let cleanPathname = pathname;
      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 homepage case deserves attention. Without the special handling, switching languages on the homepage can produce stacked URLs like /en/de on the second switch. Using router.replace('/', {locale}) prevents this.

Controlling Language Switcher Visibility

You don't want a language switcher appearing on non-internationalized routes like /blog or /api — clicking it would produce a 404. The fix is checking whether the current route is defined in 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';

function useIsInternationalizedRoute() {
  const pathname = usePathname();

  return Object.values(routing.pathnames).some((localePaths) => {
    if (typeof localePaths === 'string') {
      return pathname.match(
        new RegExp(`^/(en|de|sl)${localePaths === '/' ? '/?$' : localePaths + '/?$'}`)
      );
    }
    return Object.values(localePaths).some((localizedPath) =>
      pathname.match(new RegExp(`^/(en|de|sl)${localizedPath + '/?$'}`))
    );
  });
}

export default function Navigation() {
  const showLanguageSwitcher = useIsInternationalizedRoute();

  return (
    <nav>
      <div className="flex items-center space-x-6">
        {showLanguageSwitcher && <LanguageSwitcher />}
      </div>
    </nav>
  );
}

Your routing.pathnames config becomes the single source of truth — the switcher appears only where localized versions actually exist.

Client Components and Translations

Client components can't use setRequestLocale or getTranslations — those are server-only. There are two patterns for getting translations into client components.

The first and most performant: resolve translations in a Server Component parent and pass them as props:

// Server Component
import {getTranslations} from 'next-intl/server';
import {ClientButton} from './ClientButton';

export async function ServerWrapper() {
  const t = await getTranslations('common');
  return <ClientButton label={t('buttons.submit')} />;
}

The second option: wrap the client component with NextIntlClientProvider and pass a subset of messages:

import {NextIntlClientProvider, useMessages} from 'next-intl';
import pick from 'lodash/pick';
import {ClientCounter} from './ClientCounter';

export default function Counter() {
  const messages = useMessages();

  return (
    <NextIntlClientProvider messages={pick(messages, 'Counter')}>
      <ClientCounter />
    </NextIntlClientProvider>
  );
}

Passing only the relevant message namespace keeps the client bundle small. Don't pass the entire messages object to client components.

Route Groups: Clean Architecture

For larger apps, route groups give you a clean separation between internationalized and non-internationalized routes:

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

This makes the architecture immediately clear to anyone reading the codebase — no need to check the middleware matcher to understand which routes are internationalized.

Common Errors and Fixes

A few errors you'll likely encounter:

"Couldn't find next-intl config file" — The withNextIntl plugin in next.config.ts can't find your request config at the specified path. Double-check the path matches your actual file location. If you're on Turbopack, you may need a turbopack.resolveAlias config — see the dedicated fix guide.

"Usage of next-intl APIs in Server Components currently opts into dynamic rendering" — You have generateStaticParams but are missing setRequestLocale in one or more pages. Check every file under [locale]/ that calls a next-intl API.

"params.locale should be awaited before using its properties" — In Next.js 15+, route params are a Promise. Use const {locale} = await params before accessing locale.

Locale stacking in URLs (/en/de/page) — Your language switcher isn't cleaning the current locale prefix before navigating. See the homepage handling in the LanguageSwitcher section above.

Next Steps: SEO and CMS Integration

With routing, static generation, and translations in place, two things remain for a production-ready multilingual site.

SEO: Add canonical tags and hreflang so search engines correctly index each language version and don't treat them as duplicate content.

CMS localization: If you're using Payload CMS, the multilingual admin interface guide covers field-level localization so content editors can manage translations directly in the CMS.

The setup in this guide is what I run in production across multiple client sites. The modular message structure scales without getting messy, static generation keeps performance solid, and the TypeScript integration catches translation key errors before they reach production.

For larger applications where routing decisions compound — route groups, CMS-driven translations, per-locale performance tuning — the Next.js internationalization architecture guide covers the structural decisions that go beyond a configuration walkthrough.

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

📄View markdown version
4

Comments

Leave a Comment

Your email will not be published

Stay updated! Get our weekly digest with the latest learnings on NextJS, React, AI, and web development tips delivered straight to your inbox.

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

No comments yet

Be the first to share your thoughts on this post!

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

How to Fix "Couldn't find next-intl config file" Error in Next.js 15
How to Fix "Couldn't find next-intl config file" Error in Next.js 15

23rd September 2025

Table of Contents

  • Installation and Initial Setup
  • Middleware: The Traffic Controller
  • Next.js Configuration
  • Request Configuration: Loading the Right Messages
  • Message Organization
  • Directory Structure
  • Layout Configuration and Static Generation
  • Understanding setRequestLocale (And Why Every Page Needs It)
  • The problem it solves
  • Why you need it in both layout AND every page
  • The strict ordering rule
  • Version note
  • Future of setRequestLocale
  • Pages: The Full Pattern
  • TypeScript Type Safety for Translation Keys
  • Handling Nested Routes
  • Building a Language Switcher
  • Controlling Language Switcher Visibility
  • Client Components and Translations
  • Route Groups: Clean Architecture
  • Common Errors and Fixes
  • Next Steps: SEO and CMS Integration
On this page:
  • Installation and Initial Setup
  • Middleware: The Traffic Controller
  • Next.js Configuration
  • Request Configuration: Loading the Right Messages
  • Message Organization
Build With Matija Logo

Build with Matija

Modern websites, content systems, and AI workflows built for long-term growth.

Services

  • Headless CMS Websites
  • Next.js & Headless CMS Advisory
  • AI Systems & Automation
  • Website & Content Audit
  • Resources

    • Case Studies
    • How I Work
    • Blog
    • CMS Hub
    • E-commerce Hub
    • Dashboard

    Headless CMS

    • Payload CMS Developer
    • CMS Migration
    • Payload vs Sanity
    • Payload vs WordPress
    • Payload vs Contentful

    Get in Touch

    Ready to modernize your stack? Let's talk about what you're building.

    Book a discovery callContact me →
    © 2026BuildWithMatija•All rights reserved