Back to Blog

Next.js 15 + next-intl 4 Tutorial: Routing and Multilingual SEO

SiwoerPublished: Updated:

Set up Next.js 15 App Router with next-intl 4: locale routes, messages, language switching, canonical URLs, hreflang and sitemaps, with troubleshooting tips.

6 min read · 1158 words

This guide shows how to build a production-ready multilingual site with Next.js App Router and next-intl. The examples target Next.js 15, React 19, and next-intl 4.

By the end, you will have locale URLs such as /en/about and /zh/about, server-rendered translations, a path-preserving language switcher, localized metadata, hreflang, and sitemap alternates.

For the architectural mistakes behind this setup, read Next.js i18n Mistakes: Why I Switched to next-intl.

This tutorial uses the Next.js 15 middleware.ts convention. Next.js 16 renamed it to proxy.ts; consult the official Proxy documentation when upgrading. Examples assume a src directory; create the ordinary pages shown in the route tree as needed.

Start with the locale URL model

Use stable, crawlable routes for each language:

/en/about
/zh/about
/en/blog/post-slug
/zh/blog/post-slug

Changing copy through React state or a cookie on one shared URL is not equivalent. A URL-based locale survives refreshes, can be shared, and gives each language a distinct indexing target.

1. Install next-intl and add messages

npm install next-intl
messages/
├── en.json
└── zh.json
{
  "home": {
    "title": "Hello world",
    "description": "Welcome to my website"
  }
}

Group keys by page or feature—home, about, blog—instead of keeping a large flat file.

2. Configure routing and request messages

Create src/i18n/routing.ts:

import {defineRouting} from 'next-intl/routing';

export const routing = defineRouting({
  locales: ['en', 'zh'],
  defaultLocale: 'en',
  localePrefix: 'always'
});

Create src/i18n/request.ts:

import {hasLocale} from 'next-intl';
import {getRequestConfig} from 'next-intl/server';
import {routing} from './routing';

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

  return {
    locale,
    messages: (await import(`../../messages/${locale}.json`)).default
  };
});

Then connect the plugin in next.config.mjs:

import createNextIntlPlugin from 'next-intl/plugin';

const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
export default withNextIntl({});

3. Add locale middleware

Put middleware.ts in src when the project uses a src directory; otherwise keep it at the project root.

import createMiddleware from 'next-intl/middleware';
import {routing} from './i18n/routing';

export default createMiddleware(routing);

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

The matcher includes application pages while excluding API routes, Next.js internals, and files with extensions. Adjust it when your application has additional public routes.

4. Create the [locale] route tree

src/app/
└── [locale]/
    ├── layout.tsx
    ├── page.tsx
    ├── about/page.tsx
    └── blog/page.tsx

Validate the locale and provide messages in app/[locale]/layout.tsx:

import {hasLocale, NextIntlClientProvider} from 'next-intl';
import {getMessages, setRequestLocale} from 'next-intl/server';
import {notFound} from 'next/navigation';
import {routing} from '@/i18n/routing';

export default async function LocaleLayout({children, params}) {
  const {locale} = await params;
  if (!hasLocale(routing.locales, locale)) notFound();

  setRequestLocale(locale);
  const messages = await getMessages();

  return (
    <NextIntlClientProvider messages={messages}>
      {children}
    </NextIntlClientProvider>
  );
}

Only the root app/layout.tsx should render <html> and <body>; do not repeat them in this nested layout.

For static locale routes, add:

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

5. Translate Server and Client Components

Prefer server translation for page content:

import {getTranslations} from 'next-intl/server';

export default async function HomePage() {
  const t = await getTranslations('home');
  return <h1>{t('title')}</h1>;
}

Use the client hook only where interaction requires it:

'use client';
import {useTranslations} from 'next-intl';

export default function Hero() {
  const t = useTranslations('home');
  return <h1>{t('title')}</h1>;
}

Do not turn an entire page into a Client Component just to translate it. Server-rendered content reduces client JavaScript and gives crawlers complete HTML.

6. Build locale-aware navigation

Create a shared navigation layer:

import {createNavigation} from 'next-intl/navigation';
import {routing} from './routing';

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

Then preserve the current path when changing locale:

'use client';
import {usePathname, useRouter} from '@/i18n/navigation';
import {useLocale} from 'next-intl';

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

  return (
    <button onClick={() => router.replace(pathname, {
      locale: locale === 'en' ? 'zh' : 'en'
    })}>
      {locale === 'en' ? '中文' : 'English'}
    </button>
  );
}

A plain App Router next/link does not provide the old Pages Router locale behavior. Use next-intl's navigation APIs instead.

7. Localize metadata, canonical, and hreflang

export async function generateMetadata({params}) {
  const {locale} = await params;
  const url = `https://example.com/${locale}/about`;

  return {
    title: locale === 'zh' ? '关于我' : 'About me',
    alternates: {
      canonical: url,
      languages: {
        'zh-CN': 'https://example.com/zh/about',
        en: 'https://example.com/en/about'
      }
    }
  };
}

The canonical should point to the current language page. Every translated version should list itself and its corresponding alternatives. Do not connect pages that are not genuine translations.

8. Generate a multilingual sitemap

App Router can generate it natively in app/sitemap.ts:

export default function sitemap() {
  return [{
    url: 'https://example.com/en/about',
    lastModified: new Date('2026-06-25'), // Replace with the page's real update date
    alternates: {
      languages: {
        en: 'https://example.com/en/about',
        'zh-CN': 'https://example.com/zh/about'
      }
    }
  }];
}

Generate entries for every indexable post and use its real publication or modification date. Avoid pretending that all old pages changed on every build.

Pre-launch checklist

  • Deep /en and /zh URLs work on direct load and refresh
  • Unsupported locales return a real 404
  • The language switcher keeps the corresponding pathname
  • Visible copy, title, description, and page language agree
  • Canonical points to the current locale URL
  • Translated pages reference each other with hreflang
  • The sitemap contains every indexable locale page
  • robots.txt does not block a locale directory
  • Missing messages never expose raw translation keys

Common questions

Should language be detected from IP?

Browser preferences can inform an initial suggestion, but users should remain in control and every language should have a stable URL. Do not return unpredictable languages from one URL based only on IP.

Must every post be translated immediately?

No. Publish the available version and only add hreflang when a real counterpart exists. A fabricated or incomplete translation is not better SEO.

Conclusion

Reliable Next.js internationalization comes from making URLs, content, navigation, and search signals agree. Design the locale route first, connect next-intl second, and finish with localized metadata, hreflang, and sitemap coverage.

Troubleshooting the setup

ProblemWhere to look
next-intl request configuration is not foundPlugin wrapper and request file path in next.config.mjs
Switching locale keeps the old copyURL locale, message import path and client Provider
Dynamic route parameter access failsWhether the Next.js 15 example awaits params
Duplicate html/body elementsBoth root and nested layouts rendering document tags
/en/about returns 404Whether about/page.tsx from the route tree exists

The switcher preserves the pathname, not query parameters or the URL hash. If the page uses filters or anchors, preserve those explicitly when switching languages.

Official references

After implementation, use the i18n troubleshooting checklist before submitting the sitemap.

Frequently asked questions

Why use next-intl for Next.js App Router internationalization?
next-intl supports App Router, Server Components, Client Components, message formatting, and locale-aware navigation in one focused integration.
Does a multilingual site need separate locale URLs?
For public content that should be indexed by language, stable URLs such as /en and /zh give users and search engines clear, shareable versions.
Do Next.js multilingual pages still need hreflang?
Yes. Hreflang identifies corresponding language versions and should stay consistent with canonical URLs and sitemap alternates.
Next.js 15 + next-intl 4 Tutorial: Routing and Multilingual SEO