How to Add Translations to Next.js Projects

How to Add Translations to Next.js ProjectsHow to Add Translations to Next.js Projects

Nov 18, 2025 - 7 min

Roko Ponjarac

Roko Ponjarac

Software Engineer


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, use useTranslations. 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 params object to the router change. The URL shows literal [slug] brackets without it.

Adding More Translations

  1. Make JSON files in every language folder (messages/en/new-ns.json, messages/hr/new-ns.json).
  2. In next.config.js, add the English file to createMessagesDeclaration.
  3. In src/i18n/request.ts, import the namespace.
  4. In src/i18n/global.d.ts, add a type declaration.
  5. 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.

Ready to talk?

Send a brief introduction to schedule a discovery call. The call focuses on your challenges and goals and outlines the first steps toward the right digital solution.

How to Add Translations to Next.js Projects | Workspace