i18n with NextJS output export
Tags: nextjs, i18n, internationalization, typescript
Published: 2024-02-06 20:44:52

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ibnkrkunwwn87o4l9exo.png)

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`
```

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"](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ffwmsy8pp3fiq4csz80d.png)

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](https://www.npmjs.com/package/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`
```

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;
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jd3m1r3xvj6jfr9tq1o5.png)

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;
```

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 };
};
```

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>
  );
}
```

It will display something similar to the following:

![Hello World](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/g0x5w90losm7f8ozuzas.png)

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;
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/39yib43wqcbuoymyrt4q.png)

Now, let's implement language detection using [next-language-detector](https://www.npmjs.com/package/next-language-detector) to cache our chosen language.

Begin by installing `next-language-detector`:

```
npm i next-language-detector
```

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,
});
```

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 <></>;
};
```

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;
```

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>
  );
};
```

Now add the LanguageWrapper in your _app.tsx file:

![_app.tsx file with LanguageWrapper](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/29bc07vta3ak6cnooiig.png)

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>
  );
};
```

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 };
};
```

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

![Usage of useRouteRedirect hook](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ence2oltrv1xrk67ig8v.png)

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>
  );
};
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/w68zgdlq0mmqsfdawaax.png)

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;
```
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:

```json
"preview": "serve out/ -p 3000"
```

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

```bash
npm i -D serve
```

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](https://github.com/ikramdeveloper/i18n-nextjs-output-export). 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!