Internationalization, or i18n for short, is the process of designing and developing software applications that can be adapted to different languages and cultures. It is an important consideration for any application that is intended for a global audience.
Next.js is a popular framework for building web applications that simplifies the process of implementing i18n. In this article, we will explore how to handle i18n in Next.js using the app directory and the i18next library. We will also cover some of the key translation functions and techniques that you can use to make your application more accessible to users around the world.
I have written in another article about how to do i81n in Next.js using the URL segment to detect the locale. In this article, we will use the cookie-based approach instead.
Initialize a NextJS project
The easiest way to follow this guide is to degit a Nextjs boilerplate.
bash
1npx degit codegino/nextjs-ts-tw-tldr next13-i18n
I will be using TailwindCSS and TypeScript due to personal preference, but you can use plain CSS and JavaScript if you want.
Install dependencies
bash
1npm i
Remove unused files
Delete everything under the app and components folders
bash
1rm -rf app/* components/*
Project Setup
Our Internationalization strategy
In this blog post, we will use a cookie-based strategy to determine the locale of our web application. We can effortlessly obtain the locale from the cookies function exposed by next/headers in a server component. In a client component, we will create a custom hook to get the locale.
Install required dependencies
There are various libraries available for handling translations, but I find libraries from i18next very easy to use.
i18next-resources-to-backend is a very small utility, so you can just copy the implementation if you don't want an additional dependency.
Create a reusable settings file
Let's create a utility file for both the server and the client-side translations
i18n/settings.ts
123456789101112131415161718importtype{InitOptions}from'i18next';exportconstFALLBACK_LOCALE='en';exportconst supportedLocales =['en','zh-CN','sv']asconst;exporttypeLocales=(typeof supportedLocales)[number];// You can name the cookie to whatever you wantexportconstLANGUAGE_COOKIE='preferred_language';exportfunctiongetOptions(lang =FALLBACK_LOCALE, ns ='common'): InitOptions {return{// debug: true, // Set to true to see console logs supportedLngs: supportedLocales, fallbackLng:FALLBACK_LOCALE, lng: lang, ns,};}
123456789101112131415161718192021222324252627282930313233343536import{createInstance}from'i18next';import resourcesToBackend from'i18next-resources-to-backend';import{initReactI18next}from'react-i18next/initReactI18next';import{FALLBACK_LOCALE,getOptions,Locales,LANGUAGE_COOKIE}from'./settings';import{cookies}from'next/headers';asyncfunctioninitI18next(lang: Locales, namespace:string){const i18nInstance =createInstance();await i18nInstance.use(initReactI18next).use(resourcesToBackend(// Get the JSON file that matches the locale and namespace(lang:string, ns:string)=>import(`./locales/${lang}/${ns}.json`),),)// Initialize i18next with the options we created earlier.init(getOptions(lang, namespace));return i18nInstance;}// This function will be used in our server components for the translationexportasyncfunctioncreateTranslation(ns:string){const lang =getLocale();const i18nextInstance =awaitinitI18next(lang, ns);return{ t: i18nextInstance.getFixedT(lang,Array.isArray(ns)? ns[0]: ns),};}// Utility function to get the locale from server componentsexportfunctiongetLocale(){return(cookies().get(LANGUAGE_COOKIE)?.value ??FALLBACK_LOCALE)as Locales;}
Create translation files
Unless our users use translation plugins like Google Translate, there is no way for our content to be magically translated. Therefore, we need to determine how our pages will be translated based on the selected locale and namespace.
Here is what our translation files' structure will look like. en is the locale, and home is the namespace.
NextJS has a feature that automatically creates a layout component if we don't provide one. However, I prefer to create my own layout component because I need to basic customization.
app/layout.tsx
1234567891011121314151617importReactfrom'react';import{getLocale}from'../i18n/server';import'../styles/tailwind.css';exportconst metadata ={ title:'Next.js i18n',};exportdefaultfunctionRootLayout({children}:{children:React.ReactNode}){const locale =getLocale();return(// It's good to sync the html lang attribute with the locale<htmllang={locale}><bodyclassName="p-3">{children}</body></html>);}
Testing our page
By default, our page will use the fallback locale. Then, I manually update the cookie and refresh the page to see the changes.
Locale switcher
It will be a weird application if we need to update the cookie manually in the dev tools. So, let's create a locale switcher component.
Create a server action to update the cookie
We will take advantage of server actions to update the cookie easily.
actions/switch-locale.ts
12345678910111213'use server';import{cookies}from'next/headers';import{LANGUAGE_COOKIE}from'../i18n/settings';exportasyncfunctionswitchLocaleAction(value:string){cookies().set(LANGUAGE_COOKIE, value);// It does not matter what we return herereturn{ status:'success',};}
Now it's easier to test around what language we want to see.
Client-side translation
Now, we need to find a way to translate the content on the client side. We will use almost the same approach as the server-side translation but with additional configuration and a custom React hook.
In this section, we will update the locale switcher options to display translated text.
Create a global provider of the locale
I could not make the react-i18n provider work so I created a simple provider and hook to share the active locale.
The code below might be lengthy because we need to support both server rendering and client rendering. Don't confuse SSR with Server Component rendering.
i18n/client.ts
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364'use client';import{useEffect}from'react';import i18next,{i18n}from'i18next';import{initReactI18next, useTranslation as useTransAlias}from'react-i18next';import resourcesToBackend from'i18next-resources-to-backend';import LanguageDetector from'i18next-browser-languagedetector';import{ Locales,LANGUAGE_COOKIE, getOptions, supportedLocales,}from'./settings';import{useLocale}from'../hooks/locale-provider';const runsOnServerSide =typeof window ==='undefined';// Initialize i18next for the client sidei18next.use(initReactI18next).use(LanguageDetector).use(resourcesToBackend((lang:string, ns:string)=>import(`./locales/${lang}/${ns}.json`),),).init({...getOptions(), lng:undefined,// detect the language on the client detection:{// We only care about the cookie order:['cookie'],// If `lookupCookie` is not set, it will use `i18next` as the cookie name lookupCookie:LANGUAGE_COOKIE,// This will automatically update the cookie caches:['cookie'],}, preload: runsOnServerSide ? supportedLocales :[],});exportfunctionuseTranslation(ns:string){const lng =useLocale();const translator =useTransAlias(ns);const{i18n}= translator;// Run content is being rendered on server sideif(runsOnServerSide && lng && i18n.resolvedLanguage !== lng){ i18n.changeLanguage(lng);}else{// Use our custom implementation when running on client side// eslint-disable-next-line react-hooks/rules-of-hooksuseCustomTranslationImplem(i18n, lng);}return translator;}functionuseCustomTranslationImplem(i18n: i18n, lng: Locales){// This effect changes the language of the application when the lng prop changes.useEffect(()=>{if(!lng || i18n.resolvedLanguage === lng)return; i18n.changeLanguage(lng);},[lng, i18n]);}
Update the ChangeLocale to use the new hook
components/ChangeLocale.tsx
123456789101112131415161718192021222324'use client';importReactfrom'react';import{switchLocaleAction}from'../actions/switch-locale';import{useTranslation}from'../i18n/client';// We removed the `locale` prop because we can get it from the hookexportdefaultfunctionChangeLocale(){const{i18n, t}=useTranslation('common');// You can also use our custom hook instead of `i18n.resolvedLanguage`// const locale = useLocale();return(<div><selectonChange={e =>switchLocaleAction(e.target.value)}value={i18n.resolvedLanguage}><optionvalue="en">🇺🇸 {t('english')}</option><optionvalue="zh-CN">🇨🇳 {t('chinese')}</option><optionvalue="sv">🇸🇪 {t('swedish')}</option></select></div>);}
After all those many changes, we can now see our locale switcher with the translated options. Also, the cookie value is updated automatically to the fallback when we first load our page.
Bonus
Nested translation keys and default translation
We are not limited to a flat JSON structure.
i18n/locales/en/newsletter.json
123456789101112{"title":"Stay up to date","subtitle":"Subscribe to my newsletter","form":{"firstName":"First name","email":"E-mail","action":{"signUp":"Sign Up","cancel":"Cancel"}}}
We can omit some translation keys if we want it to use the default locale value(en in our case. See the demo later).
123456789101112{"title":"Håll dig uppdaterad","subtitle":"Prenumerera på mitt nyhetsbrev","form":{"firstName":"Förnamn","email":"E-post","action":{"signUp":"Registrera sig","cancel":"Annullera"}}}
Create the component
Let's create a component that uses the translations above. We'll make this a server component to demonstrate one way of using the createTranslation function.