DEV Community

Cover image for i18n with NextJS output export
Ikram Ul Haq
Ikram Ul Haq

Posted on

i18n with NextJS output export

In this post, I'll guide you through the process of setting up internationalization (i18n) in a Next.js project. We'll explore how to integrate i18n with the page router and address the export output option, overcoming challenges where Next.js doesn't provide direct assistance.

Next.js does not support i18n with output export

To begin, let's initialize a Next.js v14 project with TypeScript. Alternatively, you can opt for JavaScript; either choice works seamlessly.

Initializing NextJS Project

Execute the following command to set up your project:

`npx create-next-app@latest`
Enter fullscreen mode Exit fullscreen mode

During the setup, you'll encounter several questions; respond based on your preferences. In my case, I opted for the page router and specified the src directory with TypeScript.

Afterward, include the output: "export" in the next.config.js file:

next configuration with output: "export"

Additionally, remove the api folder from the pages directory, as it's incompatible with the output: "export" configuration.

Installing and Configuring i18n

For internationalization, we'll be utilizing the next-translate package. Although it doesn't inherently support output: export, don't fret – that's where our assistance comes in.

Begin by installing the next-translate package:

`npm i next-translate`
Enter fullscreen mode Exit fullscreen mode

Generate a file named i18n.ts or .js at the project's root level, and insert the following code:

import { I18nConfig } from "next-translate";

export const i18nConfig = {
  locales: ["en", "es"],
  defaultLocale: "en",
  loader: false,
  pages: {
    "*": ["common"],
  },
  defaultNS: "common",
} satisfies I18nConfig;
Enter fullscreen mode Exit fullscreen mode

We've established the fundamental configuration for i18n. Feel free to customize it based on your specific needs.

Now, establish a locales folder at the project's root level. This folder will house translation files for each language.

locales folder structure

Navigate to the _app.ts file within the pages directory, and envelop the Component with I18nProvider:

import "@/styles/globals.css";
import type { AppProps } from "next/app";
import I18nProvider from "next-translate/I18nProvider";
import { i18nConfig } from "../../i18n";
import commonES from "../../locales/es/common.json";
import commonEN from "../../locales/en/common.json";

const App = ({ Component, pageProps, router }: AppProps) => {
  const lang = i18nConfig.locales.includes(router.query.locale as string)
    ? String(router.query.locale)
    : i18nConfig.defaultLocale;

  return (
    <I18nProvider
      lang={lang}
      namespaces={{ common: lang === "es" ? commonES : commonEN }}
    >
      <Component {...pageProps} />
    </I18nProvider>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

In this step:

  • We enclosed the Component with I18nProvider.
  • Detected the language from the route and passed it as a prop to I18nProvider.
  • Defined the namespace file based on the current language.

Next, generate a custom hook named useI18n.ts in the src/hooks directory and insert the code there:

import useTranslation from "next-translate/useTranslation";
import { i18nConfig } from "../../i18n";

interface IUseI18n {
  namespace?: string;
}

export const useI18n = ({ namespace }: IUseI18n = {}) => {
  const { t } = useTranslation(namespace ? namespace : i18nConfig.defaultNS);

  return { t };
};
Enter fullscreen mode Exit fullscreen mode

This hook will supply us with translations based on the provided namespace or the default namespace.

Now, proceed to the index.tsx file in the pages directory and include the following code:

import { useI18n } from "@/hooks/useI18n";

export default function Home() {
  const { t } = useI18n();
  return (
    <div>
      <p>{t("greeting")}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

It will display something similar to the following:

Hello World

Hurrah! We've successfully configured i18n in our project. However, the language detection aspect is still pending. Let's implement that part.

Language Detection

As observed earlier, we were fetching the locale value from router.query. To proceed, establish a folder named [locale] within the pages directory. Note that all our route files will be contained within this folder. Create an index.tsx file in the [locale] folder and insert the following code:

import { useI18n } from "@/hooks/useI18n";

const Home = () => {
  const { t } = useI18n();

  return (
    <div>
      <p>{t("greeting")}</p>
    </div>
  );
};

export default Home;
Enter fullscreen mode Exit fullscreen mode

So, visiting localhost:3000/en will display content in English, and changing it to localhost:3000/es will show content in Spanish. This functionality also seamlessly integrates with dynamic routes.

The structure of your pages folder will resemble this:

Pages folder structure with locale folder

Now, let's implement language detection using next-language-detector to cache our chosen language.

Begin by installing next-language-detector:

npm i next-language-detector
Enter fullscreen mode Exit fullscreen mode

Create a folder named lib in the src directory, and within it, generate a file named languageDetector.ts. Open the file and insert the following code:

import nextLanguageDetector from "next-language-detector";
import { i18nConfig } from "../../i18n";

export const languageDetector = nextLanguageDetector({
  supportedLngs: i18nConfig.locales,
  fallbackLng: i18nConfig.defaultLocale,
});
Enter fullscreen mode Exit fullscreen mode

Generate another file named redirect.tsx in the same lib folder and insert the following code there:

import { useRouter } from "next/router";
import { useEffect } from "react";
import { languageDetector } from "./languageDetector";

export const useRedirect = (to?: string) => {
  const router = useRouter();
  const redirectPath = to || router.asPath;

  // language detection
  useEffect(() => {
    const detectedLng = languageDetector.detect();
    if (redirectPath.startsWith("/" + detectedLng) && router.route === "/404") {
      // prevent endless loop
      router.replace("/" + detectedLng + router.route);
      return;
    }

    if (detectedLng && languageDetector.cache) {
      languageDetector.cache(detectedLng);
    }
    router.replace("/" + detectedLng + redirectPath);
  });

  return <></>;
};
Enter fullscreen mode Exit fullscreen mode

Now, open the index.tsx file in the pages directory (outside the [locale] folder) and replace the existing code with the following:

import { useRedirect } from "@/lib/redirect";

const Redirect = () => {
  useRedirect();
  return <></>;
};

export default Redirect;
Enter fullscreen mode Exit fullscreen mode

If anyone attempts to access the home page without specifying a locale, they will now be redirected to the locale page.

However, creating a redirection page for all routes within the [locale] folder may not be the most efficient practice. To address this, let's create a language wrapper that redirects to the route with the language if a user tries to access a page without specifying a language.

Start by creating a folder named wrappers inside the src directory. Within this folder, generate a file named LanguageWrapper.tsx and insert the following code:

import { ReactNode, useEffect } from "react";
import { useRouter } from "next/router";
import { languageDetector } from "@/lib/languageDetector";
import { i18nConfig } from "../../i18n";

interface LanguageWrapperProps {
  children: ReactNode;
}

export const LanguageWrapper = ({ children }: LanguageWrapperProps) => {
  const router = useRouter();
  const detectedLng = languageDetector.detect();

  useEffect(() => {
    const {
      query: { locale },
      asPath,
      isReady,
    } = router;

    // Check if the current route has accurate locale
    if (isReady && !i18nConfig.locales.includes(String(locale))) {
      if (asPath.startsWith("/" + detectedLng) && router.route === "/404") {
        return;
      }

      if (detectedLng && languageDetector.cache) {
        languageDetector.cache(detectedLng);
      }
      router.replace("/" + detectedLng + asPath);
    }
  }, [router, detectedLng]);

  return (router.query.locale &&
    i18nConfig.locales.includes(String(router.query.locale))) ||
    router.asPath.includes(detectedLng ?? i18nConfig.defaultLocale) ? (
    <>{children}</>
  ) : (
    <p>Loading...</p>
  );
};
Enter fullscreen mode Exit fullscreen mode

Now add the LanguageWrapper in your _app.tsx file:

_app.tsx file with LanguageWrapper

We're almost done, just a few touches left. Let's create a component named Link.tsx in the src/components/_shared directory and insert the following code:

import { ReactNode } from "react";
import NextLink from "next/link";
import { useRouter } from "next/router";

interface LinkProps {
  children: ReactNode;
  skipLocaleHandling?: boolean;
  locale?: string;
  href: string;
  target?: string;
}

export const Link = ({
  children,
  skipLocaleHandling,
  target,
  ...rest
}: LinkProps) => {
  const router = useRouter();
  const locale = rest.locale || (router.query.locale as string) || "";

  let href = rest.href || router.asPath;
  if (href.indexOf("http") === 0) skipLocaleHandling = true;
  if (locale && !skipLocaleHandling) {
    href = href
      ? `/${locale}${href}`
      : router.pathname.replace("[locale]", locale);
  }

  return (
    <NextLink href={href} target={target}>
      {children}
    </NextLink>
  );
};
Enter fullscreen mode Exit fullscreen mode

We will utilize this Link component throughout our project instead of next/link component.

Now, create a file named useRouteRedirect.ts in the hooks folder and insert the following code:

import { useRouter } from "next/router";
import { i18nConfig } from "../../i18n";
import { languageDetector } from "@/lib/languageDetector";

export const useRouteRedirect = () => {
  const router = useRouter();

  const redirect = (to: string, replace?: boolean) => {
    const detectedLng = i18nConfig.locales.includes(String(router.query.locale))
      ? String(router.query.locale)
      : languageDetector.detect();
    if (to.startsWith("/" + detectedLng) && router.route === "/404") {
      // prevent endless loop
      router.replace("/" + detectedLng + router.route);
      return;
    }

    if (detectedLng && languageDetector.cache) {
      languageDetector.cache(detectedLng);
    }
    if (replace) {
      router.replace("/" + detectedLng + to);
    } else {
      router.push("/" + detectedLng + to);
    }
  };

  return { redirect };
};
Enter fullscreen mode Exit fullscreen mode

We will use this custom hook instead of router.push and router.replace as illustrated below:

Usage of useRouteRedirect hook

Now, create a LanguageSwitcher.tsx component in the src/components directory to switch to a specific language with the following code:

import { languageDetector } from "@/lib/languageDetector";
import { useRouter } from "next/router";
import Link from "next/link";

interface LanguageSwitcherProps {
  locale: string;
  href?: string;
  asPath?: string;
}

export const LanguageSwitcher = ({
  locale,
  ...rest
}: LanguageSwitcherProps) => {
  const router = useRouter();

  let href = rest.href || router.asPath;
  let pName = router.pathname;
  Object.keys(router.query).forEach((k) => {
    if (k === "locale") {
      pName = pName.replace(`[${k}]`, locale);
      return;
    }
    pName = pName.replace(`[${k}]`, String(router.query[k]));
  });
  if (locale) {
    href = rest.href ? `/${locale}${rest.href}` : pName;
  }

  return (
    <Link
      href={href}
      onClick={() =>
        languageDetector.cache ? languageDetector.cache(locale) : {}
      }
    >
      <button style={{ fontSize: "small" }}>{locale}</button>
    </Link>
  );
};
Enter fullscreen mode Exit fullscreen mode

Congratulations! You have successfully implemented i18n in your Next.js project with output: export. Keep in mind that if you want to load translations from different namespaces, like the 'dynamic' namespace, you'll need to define the namespaces and their translation files in _app.tsx within the I18nProvider component:

Loading namespace files according to the current language

Before testing it after creating a build, first, create a 404.tsx file in the pages directory with the following code:

import { FC, useEffect, useState } from "react";
import { NextRouter, useRouter } from "next/router";
import { getRouteRegex } from "next/dist/shared/lib/router/utils/route-regex";
import { getClientBuildManifest } from "next/dist/client/route-loader";
import { parseRelativeUrl } from "next/dist/shared/lib/router/utils/parse-relative-url";
import { isDynamicRoute } from "next/dist/shared/lib/router/utils/is-dynamic";
import { removeTrailingSlash } from "next/dist/shared/lib/router/utils/remove-trailing-slash";
import { Link } from "@/components/_shared/Link";

async function getPageList() {
  if (process.env.NODE_ENV === "production") {
    const { sortedPages } = await getClientBuildManifest();
    return sortedPages;
  } else {
    if (typeof window !== "undefined" && window.__BUILD_MANIFEST?.sortedPages) {
      console.log(window.__BUILD_MANIFEST.sortedPages);
      return window.__BUILD_MANIFEST.sortedPages;
    }
  }
  return [];
}

async function getDoesLocationMatchPage(location: string) {
  const pages = await getPageList();

  let parsed = parseRelativeUrl(location);
  let { pathname } = parsed;
  return pathMatchesPage(pathname, pages);
}

function pathMatchesPage(pathname: string, pages: string[]) {
  const cleanPathname = removeTrailingSlash(pathname);

  if (pages.includes(cleanPathname)) {
    return true;
  }

  const page = pages.find(
    (page) => isDynamicRoute(page) && getRouteRegex(page).re.test(cleanPathname)
  );

  if (page) {
    return true;
  }
  return false;
}

/**
 * If both asPath and pathname are equal then it means that we
 * are on the correct route it still doesnt exist
 */
function doesNeedsProcessing(router: NextRouter) {
  const status = router.pathname !== router.asPath;
  console.log("Does Needs Processing", router.asPath, status);
  return status;
}

const Custom404 = () => {
  const router = useRouter();

  const [isNotFound, setIsNotFound] = useState(false);

  const processLocationAndRedirect = async (router: NextRouter) => {
    if (doesNeedsProcessing(router)) {
      const targetIsValidPage = await getDoesLocationMatchPage(router.asPath);
      if (targetIsValidPage) {
        await router.replace(router.asPath);
        return;
      }
    }
    setIsNotFound(true);
  };

  useEffect(() => {
    if (router.isReady) {
      processLocationAndRedirect(router);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [router.isReady]);

  if (!isNotFound) return null;

  return (
    <div className="fixed inset-0 flex justify-center items-center">
      <div className="flex flex-col gap-10">
        <h1>Custom 404 - Page Not Found</h1>
        <Link href="/">
          <button>Go to Home Page</button>
        </Link>
      </div>
    </div>
  );
};

export default Custom404;
Enter fullscreen mode Exit fullscreen mode

To resolve page not found errors for dynamic routes, the inclusion of a 404.tsx file in the pages directory is essential.

Additionally, add the following command to your package.json file to execute the code after creating the build:

"preview": "serve out/ -p 3000"
Enter fullscreen mode Exit fullscreen mode

Ensure the serve package is installed if it's not already present:

npm i -D serve
Enter fullscreen mode Exit fullscreen mode

After running npm run build and then npm run preview, you can access your project on port 3000.

The complete code can be found on GitHub. Some tweaks have been added there, so make sure to check it out.

Feel free to comment if anything is missing or if you encounter any errors. I'm here to assist you. Thanks!

Top comments (5)

Collapse
 
nilsjacobsen profile image
Nils Jacobsen

Great article 👍 is there a reason you didn't use ParaglideJS?

Collapse
 
ikramdeveloper profile image
Ikram Ul Haq

Nice library but does it support NextJS with output: export?

Collapse
 
ikramdeveloper profile image
Ikram Ul Haq

Going through the library, I found it relatively new and no built in support for NextJS output export. So we are good with next-translate that is minimalist and well maintained but yeah we can give it a try.

Thread Thread
 
lorissigrist profile image
Loris Sigrist

Hi, Paraglide Maintainer here
Paraglide does work with NextJs's output: export set, but I see that you've already made up your mind. next-translate is certainly a good choice & you won't regret choosing it. We're going to keep working on Paraglide & hopefully it's going to be feature-complete enough next time!

Thread Thread
 
ikramdeveloper profile image
Ikram Ul Haq

Certainly! If it proves to be helpful, we'll definitely consider incorporating it into our project. Thank you! 👍