You need to make a website that works in more than one language if you want to reach people all over the world. If you add content in more than one language to your business website, e-commerce site, or SaaS app, it will make the user experience much better and help you reach more people. This in-depth guide will teach you how to make Next work with internationalisation (i18n). js with next-intl, a library that was made just for the App Router structure.
Installation
First, install the next-intl package using your preferred package manager:
npm install next-intl
Or with Yarn:
yarn add next-intl
Or with pnpm:
pnpm add next-intl
Why next-intl?
next-i18next was made for Pages Router. It works with App Router, but the integration feels forced. There is no built-in routing support in react-intl.
Next-intl supports:
- Native Server Components.
- TypeScript autocomplete for translation keys.
- Built-in URL localisation (for example, /about becomes /hr/o-nama).
- Automatic locale detection based on browser settings.
Project Structure
Your project should look like this:
your-project/
├── messages/
│ ├── en/
│ │ └── common.json
│ └── hr/
│ └── common.json
└── src/
├── i18n/
│ ├── routing.ts
│ ├── request.ts
│ └── navigation.ts
└── app/
└── [locale]/
├── layout.tsx
└── page.tsx
└── middleware.ts
The messages folder has JSON files for translations that are sorted by language. Configuration files are in the src/i18n folder. The [locale] folder is a dynamic route segment that gets the current language from the URL.
Translation Files
JSON files that are organised into namespaces make up translation files. There is a separate file for each namespace that groups together translations that are related.
messages/en/common.json:
{
"navigation": {
"home": "Home",
"about": "About Us"
},
"buttons": {
"submit": "Submit",
"cancel": "Cancel"
}
}
messages/hr/common.json:
{
"navigation": {
"home": "Pocetna",
"about": "O nama"
},
"buttons": {
"submit": "Posalji",
"cancel": "Odustani"
}
}
The key structures in both files must be the same. Warnings appear on the console when keys are missing.
Routing Configuration
src/i18n/routing.ts:
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'hr'],
defaultLocale: 'en',
localePrefix: 'as-needed',
pathnames: {
'/': '/',
'/about': { hr: '/o-nama' },
'/services': { hr: '/usluge' },
'/blog/[slug]': { hr: '/blog/[slug]' },
},
});
The localePrefix: "as-needed" makes URLs look nice (English shows /about and Croatian shows /hr/o-nama). The pathnames object connects internal paths to localised URLs.
Setting Up Middleware
middleware.ts (project root):
import createMiddleware from 'next-intl/middleware';
import { routing } from './src/i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
};
The middleware first looks at the URL prefix to find the locale, then it looks at cookies, and finally it looks at the browser's Accept-Language header. It sends the user to the right URL pattern and saves their preference in a cookie.
Setting Up the Layout
src/app/[locale]/layout.tsx:
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { notFound } from "next/navigation";
import { hasLocale } from "next-intl";
import { routing } from "@/i18n/routing";
export default async function RootLayout({ children, params }) {
const { locale } = params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
const messages = await getMessages();
return (
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
);
}
Using Translations
Server Components
src/app/[locale]/about/page.tsx:
import { getTranslations } from "next-intl/server";
export default async function AboutPage() {
const t = await getTranslations("common");
return (
<>
<h1>{t("navigation.about")}</h1>
<button>{t("buttons.submit")}</button>
</>
);
}
Client Components
"use client";
import { useTranslations } from "next-intl";
export default function ContactForm() {
const t = useTranslations("common");
return (
<button>{t("buttons.submit")}</button>
);
}
In server components, use
getTranslations; in client components, useuseTranslations. Translations of server components don't change the size of the client bundle at all.
Dynamic Values
// JSON: { "greeting": "Hello, {name}!" }
t('greeting', { name: 'John' });
// Output: "Hello, John!"
Language Switcher
"use client";
import { useLocale } from "next-intl";
import { useRouter, usePathname } from "@/i18n/navigation";
import { useParams } from "next/navigation";
export default function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const params = useParams();
const switchLanguage = (newLocale: string) => {
const slug = params.slug as string | undefined;
if (slug) {
router.replace({ pathname: pathname as any, params: { slug } }, { locale: newLocale });
} else {
router.replace(pathname, { locale: newLocale });
}
};
return (
<div>
<button onClick={() => switchLanguage("en")} disabled={locale === "en"}>EN</button>
<button onClick={() => switchLanguage("hr")} disabled={locale === "hr"}>HR</button>
</div>
);
}
To use dynamic routes, you need to pass the
paramsobject to the router change. The URL shows literal[slug]brackets without it.
Adding More Translations
- Make JSON files in every language folder (
messages/en/new-ns.json,messages/hr/new-ns.json). - In
next.config.js, add the English file tocreateMessagesDeclaration. - In
src/i18n/request.ts, import the namespace. - In
src/i18n/global.d.ts, add a type declaration. - Restart the dev server.
Common Issues and Solutions
Console shows "Message is missing" warning
If you see a warning that a message is missing, make sure the key exists in all language files. Keep in mind that keys are case-sensitive.
[slug] appears literally in the URL
When [slug] shows up literally in the URL instead of the actual value, you need to pass the params object to the router change on dynamic routes. See the Language Switcher example above for the correct implementation.
No TypeScript autocomplete
When autocomplete is not working, restart the dev server. Also check that the file has been added to the createMessagesDeclaration configuration.
URLs not working
If localised URLs are not working correctly, add the pathname mapping to routing.ts. Also clear your browser cache as old routes may be stored.








