---
title: "next-intl Guide: Add i18n to Next.js 16 (Complete Setup)"
slug: "nextjs-internationalization-guide-next-intl-2025"
published: "2025-09-05"
updated: "2026-03-28"
validated: "2026-03-01"
categories:
  - "Next.js"
tags:
  - "next-intl"
  - "next-intl v4"
  - "next.js 16 internationalization"
  - "nextjs i18n"
  - "next-intl setup"
  - "setRequestLocale"
  - "next-intl routing"
  - "next-intl static rendering"
  - "next-intl language switcher"
  - "nextjs multilingual"
  - "next.js localization"
  - "i18n next.js"
  - "next-intl error fix"
  - "next-intl generateStaticParams"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "next.js@15"
  - "next-intl@latest"
  - "typescript@5"
  - "node@18+"
status: "stable"
llm-purpose: "Learn how to make your Next.js website multilingual with next-intl. Includes routing, middleware, messages, locale layouts, nested routes"
llm-prereqs:
  - "Access to Next.js 15"
  - "Access to next-intl"
  - "Access to TypeScript"
llm-outputs:
  - "Completed outcome: Learn how to make your Next.js website multilingual with next-intl. Includes routing, middleware, messages, locale layouts, nested routes"
---

**Summary Triples**
- (Install next-intl, command, npm install next-intl)
- (Routing configuration, file, src/i18n/routing.ts)
- (Routing configuration, defines locales, ['en','de','sl'])
- (Routing configuration, defines defaultLocale, 'en')
- (Routing configuration, supports custom paths, per-locale pathnames for routes like /about, /contact, /services)
- (Type exports, Pathnames type, keyof typeof routing.pathnames)
- (Type exports, Locale type, (typeof routing.locales)[number])
- (Guide target, Next.js version, 15+)
- (Coverage, topics, routing, middleware, messages, locale layouts, nested routes, language switcher)
- (Production readiness, addresses, routing gotchas and middleware edge cases for App Router)
- (Message organization, recommendation, use per-locale message files and load them in server components or via next-intl loaders)

### {GOAL}
Learn how to make your Next.js website multilingual with next-intl. Includes routing, middleware, messages, locale layouts, nested routes

### {PREREQS}
- Access to Next.js 15
- Access to next-intl
- Access to TypeScript

### {STEPS}
1. Follow the detailed walkthrough in the article content below.

<!-- llm:goal="Learn how to make your Next.js website multilingual with next-intl. Includes routing, middleware, messages, locale layouts, nested routes" -->
<!-- llm:prereq="Access to Next.js 15" -->
<!-- llm:prereq="Access to next-intl" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:output="Completed outcome: Learn how to make your Next.js website multilingual with next-intl. Includes routing, middleware, messages, locale layouts, nested routes" -->

# next-intl Guide: Add i18n to Next.js 16 (Complete Setup)
> Complete next-intl v4 setup for Next.js 16: routing, middleware, setRequestLocale explained, static generation, language switcher + TypeScript. Production-ready i18n in one guide.
Matija Žiberna · 2025-09-05

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:

```bash
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.

```typescript
// 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:

```typescript
// 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.

```typescript
// 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](/blog/next-intl-nextjs-16-proxy-fix) covers the common failure patterns.
## Next.js Configuration

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

```typescript
// 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:

```typescript
// 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:

```json
// 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:

```json
// 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:

```typescript
// 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.

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```typescript
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`:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```typescript
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](/blog/fix-nextintl-turbopack-error).

**"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](/blog/nextjs-advanced-seo-multilingual-canonical-tags) 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](/blog/payload-cms-multilingual-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](/blog/nextjs-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

## LLM Response Snippet
```json
{
  "goal": "Learn how to make your Next.js website multilingual with next-intl. Includes routing, middleware, messages, locale layouts, nested routes",
  "responses": [
    {
      "question": "What does the article \"How to Make Your Next.js Website Multilingual with next-intl in 2025\" cover?",
      "answer": "Learn how to make your Next.js website multilingual with next-intl. Includes routing, middleware, messages, locale layouts, nested routes"
    }
  ]
}
```