How to Implement i18n Internationalization in a Next.js App Directory

tandk8600

TandK8600

Posted on November 25, 2024

How to Implement i18n Internationalization in a Next.js App Directory

This tutorial is suitable for implementing relatively simple projects. If you're new to Next.js and don't want to use overly complex methods to implement a multilingual project, then this tutorial is right for you.

This tutorial is applicable to Next.js projects with the App directory.

Here are some links to refer to:

Tutorial from the Nextjs.org

Code demo for reference

Implementation Idea

Let's explain the general logic in combination with the file structure:

Next.js Project App Directory i18n Structure

  • i18n-config.ts is just a global enumeration file that manages multi-language abbreviations, and other files can reference this file, preventing mismatches between different files.

  • middleware.ts acts as an interceptor, which can determine the user's preferred language based on the request header when a user visits localhost:3000. It works with the newly added [lang] directory in the App directory to achieve redirection to localhost:3000/en, for example.

  • The dictionaries folder contains JSON fields for different languages. By referencing these fields, the page displays different languages.

In fact, both layout.tsx and page.tsx will pass language as a parameter. In the corresponding files, you can read the content from the corresponding JSON file by calling the method in get-dictionaries.

Specific Code

The general idea is described above. Below is the corresponding code.

The code for /i18n-config.ts:

// /i18n-config.ts

export const i18n = {
    defaultLocale: "en",
    // locales: ["en", "zh", "es", "hu", "pl"],
    locales: ["en", "zh"],
} as const;

export type Locale = (typeof i18n)["locales"][number];
Enter fullscreen mode Exit fullscreen mode

Install dependencies:

npm install @formatjs/intl-localematcher
npm install negotiator
Enter fullscreen mode Exit fullscreen mode

Then comes the code for /middleware.ts:

// middleware.ts

import { NextRequest, NextResponse } from 'next/server';
import { Locale, i18n } from './i18n-config';
import Negotiator from 'negotiator';

export async function middleware(req: NextRequest) {
  const negotiator = new Negotiator(req);

  const acceptLanguages = negotiator.languages();
  const preferredLocale = acceptLanguages[0] as Locale;

  if (
    i18n.locales.includes(preferredLocale) &&
    req.nextUrl.locale !== preferredLocale
  ) {
    return NextResponse.redirect(
      new URL(
        `/${preferredLocale}${req.nextUrl.pathname.startsWith('/') ? req.nextUrl.pathname : `/${req.nextUrl.pathname}`}`,
        req.url
      )
    );
  }

  return NextResponse.next();
}

export const config = {
  matcher: '/((?!api|_next|static|favicon.ico).*)',
};


typescript
// /middleware.ts

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

import { i18n } from "./i18n-config";

import { match as matchLocale } from "@formatjs/intl-localematcher";
// @ts-ignore
import Negotiator from "negotiator";

function getLocale(request: NextRequest): string | undefined {
  // Negotiator expects plain object so we need to transform headers
  const negotiatorHeaders: Record<string, string> = {};
  request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));

  // @ts-ignore locales are readonly
  const locales: string[] = i18n.locales;

  // Use negotiator and intl-localematcher to get best locale
  let languages = new Negotiator({ headers: negotiatorHeaders }).languages(
    locales,
  );

  const locale = matchLocale(languages, locales, i18n.defaultLocale);

  return locale;
}

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;

  // // `/_next/` and `/api/` are ignored by the watcher, but we need to ignore files in `public` manually.
  // // If you have one
  if (
    [
      '/manifest.json',
      '/favicon.ico',
      '/logo.svg',
      '/logo.png',
      '/sitemap.xml',
    ].includes(pathname)
  )
    return;

  // Check if there is any supported locale in the pathname
  const pathnameIsMissingLocale = i18n.locales.every(
    (locale) =>
      !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`,
  );

  // Redirect if there is no locale
  if (pathnameIsMissingLocale) {
    const locale = getLocale(request);

    // e.g. incoming request is /products
    // The new URL is now /en-US/products
    return NextResponse.redirect(
      new URL(
        `/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`,
        request.url,
      ),
    );
  }
}

export const config = {
  // Matcher ignoring `/_next/` and `/api/`
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
Enter fullscreen mode Exit fullscreen mode

The /dictionaries folder is project-specific, but here's a reference:

demo of the dictionaries directory

Code for /get-dictionaries.ts:

// /get-dictionaries.ts

import "server-only";
import type { Locale } from "./i18n-config";

// We enumerate all dictionaries here for better linting and typescript support
// We also get the default import for cleaner types
const dictionaries = {
  en: () => import("./dictionaries/en.json").then((module) => module.default),
  zh: () => import("./dictionaries/zh.json").then((module) => module.default),
};

export const getDictionary = async (locale: Locale) => dictionaries[locale]?.() ?? dictionaries.en();
Enter fullscreen mode Exit fullscreen mode

Here's a reference for actual usage:

reference for actual usage

That's it! We're done.

Managing and Translating Multilingual JSON Files

The method described above uses JSON to manage multiple languages. In reality, managing these JSON files gets tedious after working on several multilingual projects.

Every time I add a key-value pair to en.json, I have to translate the corresponding ko.json, ja.json, ru.json, and so on.

Using GPT for translation works well for the first language, but it forgets the original text for subsequent languages.

Machine translation doesn't recognize JSON format, so I have to manually copy and paste the value, which is a real pain.

Considering these challenges, I developed a dedicated translator for this situation. If you're interested, check out the JSON Translator.

There's also a corresponding Markdown Translator.

Markdown files are even longer, making GPT forget the context. Machine translation has length limitations, forcing you to split the text into several segments, which also leads to loss of Markdown syntax in the translation.

This translator takes length into account and allows you to directly copy and paste an entire JSON or Markdown file for translation. It usually works well for my projects. If you have any suggestions for improvement after trying it out, feel free to contact the email address on the website. I'll consider implementing any feedback I receive.

💖 💪 🙅 🚩
tandk8600
TandK8600

Posted on November 25, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related