Next.js Internationalization Simplified

Sun Feb 09 2025

Because Nextjs has server and client components, maintaining internationalization is more difficult than it is in a traditional React application. We'll look at how to keep Nextjs app international in this article. The procedures outlined in Nextjs' documentation will be what we do. The documentation can be found here.

As stated in the official documentation, I will attempt to keep it simple. For this, we won't be using any libraries. Although a negotiator-like library can be used, subpath routing will be used in this case. The entire application must be wrapped in a [lang] route. For example, if you have a /article page, it will now be /[lang]/article, with lang representing the language code such as en, fr, es, etc. In order to handle situations where the lang code is not available, we will add a fallback code and use it to reroute the same path. Thus, middleware is used because we will be managing this in each route segment.

proxy.ts
import { NextRequest, NextResponse } from "next/server";
import { getLocale } from "./lib/i18n";

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const { hasLocale, pathname: newPath } = getLocale(pathname);

  if (!hasLocale) {
    request.nextUrl.pathname = newPath;
    return NextResponse.redirect(request.nextUrl);
  }
}

export const config = {
  matcher: ["/((?!_next).*)"],
};

This is the middleware. It runs on every request. Here's what it does, it takes the URL path, checks if it already has a locale in it using getLocale. If the locale is missing (like someone visits /article instead of /en/article), it adds the default locale to the path and redirects. The config.matcher makes sure this middleware skips internal Next.js files (the _next folder) so it only runs on actual page routes.

lib/i18n.ts
const dictionaries = {
  en: () => import("@/dict/en.json").then((module) => module.default),
  ja: () => import("@/dict/ja.json").then((module) => module.default),
} as const;

export type Dict = Awaited<ReturnType<(typeof dictionaries)["en"]>>;
export type Locale = keyof typeof dictionaries;
export const locales = Object.keys(dictionaries) as Locale[];

export const getDictionary = async (locale: string) => {
  let l = locale as Locale;
  if (!locales.includes(l)) return dictionaries[locales[0]]();
  return dictionaries[l]();
};

export function getLocale(
  pathname: string
): {
  locale: Locale;
  hasLocale: boolean;
  pathname: string;
} {
  const segments = pathname.split("/").filter(Boolean);
  const [firstSegment, ...rest] = segments;

  const isSupported = locales.includes(firstSegment as Locale);

  const locale = isSupported
    ? (firstSegment as Locale)
    : locales[0];

  const normalizedPath = isSupported
    ? `/${segments.join("/")}` // already correct
    : `/${locale}${rest.length ? "/" + rest.join("/") : ""}`;

  return {
    locale,
    hasLocale: isSupported,
    pathname: normalizedPath,
  };
}

export type LangProps = { params: Promise<{ locale: string }> };

This is the core i18n utility file.

Localization

Changing displayed content based on the user’s preferred locale, or localization, is not something specific to Next.js. The patterns described below would work the same with any web application. let's assume we have ja and en as supported languages, so we create two dictionaries for each language.

dict/en.json
{
    "home": {
        "title": "AriaDocs - Template",
        "description": "This comprehensive ....."
    }
}
dict/ja.json
{
    "home": {
        "title": "AriaDocs - テンプレート",
        "description": "Next.jsで作成されたこの包括的な ..."
    }
}

As a result, we have implemented localization and ensure that the path of our pages contains a lang code. Therefore, each page and layout will have prop lang that will be used to render the content in the appropriate language when the application is wrapped in a [lang] route. Let's see if we can accomplish this. Wrap all routes in [locale] in your app directory even layout

app/[locale]/article.tsx
import { getDictionary, LangProps } from "@/lib/i18n";

export default async function Home({ params }: LangProps) {
    const { locale } = await params;
    const dict = await getDictionary(locale);
    return <div>
        <h1>{dict.home.title}</h1>
        <p>{dict.home.description}</p>
    </div>
}

Each page will have these lang properties, which are inside the lang route, and you can now render the content from the dictionary according to the locale. The client components can then render the content according to the locale by passing this resolved dictionary to them. If the client component is located far from the lang path, we can create a context that accepts the resolved dictionary and passes it to all of its child components. This context can then be used from the useDictionary hook.

components/dict-provider.tsx
"use client";

import type { Dict } from "@/lib/i18n";
import { createContext, PropsWithChildren, useContext } from "react";

export function ClientDictionaryProvider({
  children,
  dict,
}: PropsWithChildren<{ dict: Dict }>) {
  return (
    <DictionaryContext.Provider value={{ dict }}>
      {children}
    </DictionaryContext.Provider>
  );
}

const DictionaryContext = createContext<{ dict: Dict } | null>(null);

export function useDictionary() {
  const val = useContext(DictionaryContext);
  if (!val) throw new Error("...");
  return val.dict;
}

As you can see in the code above, we have created a context that accepts the dictionary and passes it to all of its child components. The useDictionary hook can then be used to access the dictionary from any child component. This is how we can keep our Next.js application internationalized without using any libraries. The below shows how we wrapping the RootLayout with the ClientDictionary.

app/[locale]/layout.tsx
import { getDictionary, LangProps, locales } from "@/lib/i18n";
import { PropsWithChildren } from "react";
import { ClientDictionaryProvider } from "@/components/dict-provider";

export default async function RootLayout({
  children,
  params,
}: PropsWithChildren<LangProps>) {
  const { locale } = await params;
  const dict = await getDictionary(locale);
  return (
    <html lang={locale} suppressHydrationWarning>
      <body>
        <ClientDictionary dict={dict}>
            <main>
              {children}
            </main>
        </ClientDictionary>
      </body>
    </html>
  );
}

Because the error and not-found pages in Nextjs don't receive dynamic props, they should be client components that retrieve the dictionary using the useDictionary hook. An example of turning the error page into a client component can be found below.

app/[locale]/error.tsx
"use client"; // Error components must be Client Components
import { useDictionary } from "@/components/dict-provider";
import { useEffect } from "react";

export default function Error({ error }: { error: Error }) {
  const dict = useDictionary();

  useEffect(() => {
    console.error(error);
  }, [error]);

  return (
    <div>
      <p>{dict.error.something_went_wrong}</p>
      <p>{dict.error.sub_text}</p>
    </div>
  );
}

Everything works fine, but there's one problem. Since [locale] is a dynamic segment, Next.js treats all pages inside it as dynamic — meaning they get rendered on every request instead of at build time. But we already know exactly which locales we support, so we can tell Next.js to pre-build pages for each locale at build time. That's what generateStaticParams does. You export it from your layout (or any page), and Next.js will statically generate the pages for each locale returned.

app/[locale]/layout.tsx
import { locales } from "@/lib/i18n"; // already imported above

export async function generateStaticParams() {
  return locales.map((locale) => ({ locale }));
}

This returns [{ locale: "en" }, { locale: "ja" }], so Next.js will pre-build /en/... and /ja/... versions of every page at build time instead of on each request.

Finally, we can develop a lang-select component that will change the locale and reroute to the same page with the new locale. This will allow us to manually change the locale using the language dropdown in the user interface.

components/lang-select.tsx
"use client";

import { usePathname, useRouter } from "next/navigation";

const available_locales = [
  { title: "English", code: "en" },
  { title: "日本語", code: "ja" },
];

export default function LangSelect() {
  const pathname = usePathname();
  const router = useRouter();

  function handleChangeLocale(newLocale: string) {
    router.push(pathname.replace(/\/[a-z]{2}/, `/${newLocale}`));
  }

  return (
    <div className="flex gap-3 mt-1">
      {available_locales.map((item) => (
        <button
          className="cursor-pointer"
          key={item.code}
          onClick={() => handleChangeLocale(item.code)}
        >
          {item.title}
        </button>
      ))}
    </div>
  );
}

Hence we have successfully implemented internationalization in Next.js app without using any libraries. This is a simple and effective way to handle internationalization in Next.js I have used this in one of my projects, AriaDocs and you can find the source code here