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.
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:
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`
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.
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
withI18nProvider
. - 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:
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:
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
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:
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:
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:
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:
"preview": "serve out/ -p 3000"
Ensure the serve
package is installed if it's not already present:
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. 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 (9)
Great article 👍 is there a reason you didn't use ParaglideJS?
Nice library but does it support NextJS with output: export?
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.
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!Certainly! If it proves to be helpful, we'll definitely consider incorporating it into our project. Thank you! 👍
Will this work on nextjs 12?
Nardos, not sure about it. We can give it a try
@ikramdeveloper Great article. Is it possible to use Trans from next-i18next or any other approach to put custom translated text into the page?
Silvio, I haven’t used
next-i18next
as described in the article. Instead, with thenext-translate
package, there’s a useTranslation hook that helps in writing custom translated text.