How to Implement i18n Internationalization in a Next.js App Directory
TandK8600
Posted on November 25, 2024
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:
Implementation Idea
Let's explain the general logic in combination with the file 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 visitslocalhost:3000
. It works with the newly added[lang]
directory in the App directory to achieve redirection tolocalhost: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];
Install dependencies:
npm install @formatjs/intl-localematcher
npm install negotiator
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).*)"],
};
The /dictionaries
folder is project-specific, but here's a reference:
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();
Here's a 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.
Posted on November 25, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.