AA
Ayi Akbar
How to Build Multilingual Website with Next.js + Next-intl

How to Build Multilingual Website with Next.js + Next-intl

May 4, 2025 Ayi Akbar Maulana

There are many tools available today for creating multilingual websites. Browser translation can easily convert any page, but as a programmer, I want to understand exactly how to implement this myself to improve my website's appearance and user experience. So, I started browsing the internet and discovered an interesting solution: combining Next.js and next-intl to achieve this functionality.

Here’s how I approached it: I created a new project to get things started.

npx create-next-app@latest multilingual-website

Then, I changed the directory to the newly created project and installed the required next-intl package:

cd multilingual-website
npm install next-intl

I’m assuming you’re using the App Router, so we’ll follow this recommended file structure:

├── messages
│   ├── en.json
│   ├── id.json
│   └── ...
├── next.config.ts
└── src
    ├── i18n
    │   ├── routing.ts
    │   ├── navigation.ts
    │   └── request.ts
    ├── middleware.ts
    └── app
        └── [locale]
            ├── layout.tsx
            └── page.tsx

To support unique pathnames for each language your app offers, next-intl allows for two routing strategies:

  1. Prefix-based routing (e.g. /en/about)
  2. Domain-based routing (e.g. en.example.com/about)

Let’s begin setting up the files, and I’ll explain each of them clearly as we go.

1. messages/en.json

This file contains the translations specific to a language. You can create additional files like id.json or fr.json depending on the languages you want to support.

messages/en.json

{
  "HomePage": {
    "title": "Hello, I'm Ayi Akbar",
    "description": "The best way to learn is by doing."
  }
}

2. next.config.ts

We need to configure a plugin to inject request-specific i18n settings (like your messages) into Server Components.

next.config.ts

import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";

const nextConfig: NextConfig = {
  /* config options here */
};

const withNextIntl = createNextIntlPlugin();

export default withNextIntl(nextConfig);

3. src/i18n/routing.ts

You’ll need to integrate i18n into two main areas of your app:

  • Middleware: for negotiating the locale and managing redirects (e.g. / → /en)
  • Navigation APIs: lightweight wrappers around components like <Link />

So, to centralize the routing configuration, create routing.ts like this:

src/i18n/routing.ts

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

export const routing = defineRouting({
  // A list of all locales that are supported
  locales: ["en", "id"],

  // Used when no locale matches
  defaultLocale: "en",
});

4. src/i18n/navigation.ts

Once we have our routing configuration in place, we can use it to set up the navigation APIs.

src/i18n/navigation.ts

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

// Lightweight wrappers around Next.js' navigation
// APIs that take the routing configuration into account
export const {Link, redirect, usePathname, useRouter, getPathname} =
  createNavigation(routing);

5. src/middleware.ts

We can also reuse our routing configuration to set up the middleware. This helps detect the user’s locale and handle redirects accordingly.

src/middleware.ts

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

export default createMiddleware(routing);

export const config = {
  // Match all pathnames except for
  // - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel`
  // - … the ones containing a dot (e.g. `favicon.ico`)
  matcher: "/((?!api|trpc|_next|_vercel|.*\..*).*)",
};

6. src/i18n/request.ts

When using next-intl in Server Components, the relevant configuration is provided by a central module: i18n/request.ts. This allows the app to load locale-specific messages based on the current request.

src/i18n/request.ts

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

export default getRequestConfig(async ({ requestLocale }) => {
  // Typically corresponds to the `[locale]` segment
  const requested = await requestLocale;
  const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale;

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

7. src/app/[locale]/layout.tsx

The locale matched by the middleware is passed as a parameter. Here, you can validate the locale and use it to configure the HTML document’s language. This is also where we set up the NextIntlClientProvider to pass translations to client components.

src/app/[locale]/layout.tsx

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

export default async function LocaleLayout({
  children,
  params
}: {
  children: React.ReactNode;
  params: Promise<{locale: string}>;
}) {
  const {locale} = await params;

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

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider>{children}</NextIntlClientProvider>
      </body>
    </html>
  );
}

8. components/language-switcher.tsx

This component allows users to switch between languages. components/language-switcher.tsx

"use client";

import { useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import { useTransition } from "react";

export function LanguageSwitcher() {
  const router = useRouter();
  const pathname = usePathname();
  const [, startTransition] = useTransition();

  const currentLang = pathname.split("/")[1];

  const handleLangChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
    const selectedLang = event.target.value;
    const segments = pathname.split("/");
    segments[1] = selectedLang;
    const newPath = segments.join("/");

    startTransition(() => {
      router.push(newPath);
    });
  };

  return (
    <select onChange={handleLangChange} defaultValue={currentLang}>
      <option value="id">🇮🇩 Bahasa</option>
      <option value="en">🇬🇧 English</option>
    </select>
  );
}

9. src/app/[locale]/page.tsx

Now you can use translations in your components just like this:

src/app/[locale]/page.tsx

import { LanguageSwitcher } from "@/components/language-switcher";
import { useTranslations } from "next-intl";

export default function HomePage() {
  const t = useTranslations("HomePage");
  return (
    <>
      <div className="flex flex-col items-center justify-center h-screen">
        <LanguageSwitcher />

        <h1 className="text-4xl font-bold mt-10">{t("title")}</h1>
        <p className="text-lg">{t("description")}</p>
      </div>
    </>
  );
}

Here is the screenshot of the project with the multilingual feature:

English Language Screenshot project

Bahasa Indonesia Language Screenshot project 2

And that’s all it takes!

With just a few steps, you've now set up a robust multilingual system using Next.js and next-intl—so your app is ready to serve users in multiple languages, the clean and scalable way.

You can view the complete project on GitHub.