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.
With the introduction of app directory, my previous i18n blog is not applicable anymore since next-i18next is not necessary.
Installation
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 Structure
Our i18n strategy
In this blog post, we will use a path-based strategy to determine the locale of our web application. Implementing this strategy in Next.js is easy because the framework follows a convention for creating paths. We will create a [locale] folder and place all our pages inside it. This means our folder structure should look like this:
app └── [locale] ├── page.tsx └── about └── page.tsx
Since we're utilizing a path-based i18n approach, we can effortlessly obtain the locale from the params object that our page will receive.
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.
We'll get a 404 page if we try to access the root URL because every page is now under the [locale] folder. We'll handle this later.
To see our page, we need to add the "locale" of our choice to the URL. For example, if we want to see the English version of our page, we need to add /en to the URL.
Locale switcher and navigation links
To simplify our demo, we'll create a component that will handle the locale switcher and navigation links.
Implement a new layout component to manage the organization of page content
We can put everything in the root layout if you prefer.
12345678910111213141516171819202122232425262728293031'use client';importReactfrom'react';import{useRouter, useParams, useSelectedLayoutSegments}from'next/navigation';constChangeLocale=()=>{const router =useRouter();const params =useParams();const urlSegments =useSelectedLayoutSegments();consthandleLocaleChange= event =>{const newLocale = event.target.value;// This is used by the Header component which is used in `app/[locale]/layout.tsx` file,// urlSegments will contain the segments after the locale.// We replace the URL with the new locale and the rest of the segments. router.push(`/${newLocale}/${urlSegments.join('/')}`);};return(<div><selectonChange={handleLocaleChange}value={params.locale}><optionvalue="en">🇺🇸 English</option><optionvalue="zh-CN">🇨🇳 中文</option><optionvalue="sv">🇸🇪 Swedish</option></select></div>);};exportdefaultChangeLocale;
Now it's easier to test around what language and page we want to see.
Actual translation
We're done with the project setup. It's time to actually do some internationalization
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 user's locale.
Here is what our translation files' structure will look like.
Create a utility for translations happening in the server/backend
i18n/server.ts
1234567891011121314151617181920212223242526272829303132import{createInstance}from'i18next';import resourcesToBackend from'i18next-resources-to-backend';import{initReactI18next}from'react-i18next/initReactI18next';import{getOptions, LocaleTypes}from'./settings';// Initialize the i18n instanceconstinitI18next=async(lang: LocaleTypes, ns:string)=>{const i18nInstance =createInstance();await i18nInstance.use(initReactI18next).use(resourcesToBackend((language:string, namespace:typeof ns)=>// load the translation file depending on the language and namespaceimport(`./locales/${language}/${namespace}.json`),),).init(getOptions(lang, ns));return i18nInstance;};// It will accept the locale and namespace for i18next to know what file to loadexportasyncfunctioncreateTranslation(lang: LocaleTypes, ns:string){const i18nextInstance =awaitinitI18next(lang, ns);return{// This is the translation function we'll use in our components// e.g. t('greeting') t: i18nextInstance.getFixedT(lang,Array.isArray(ns)? ns[0]: ns),};}
Translate the Home page
app/[locale]/page.tsx
123456789101112131415import{createTranslation}from'../../i18n/server';// This should be async cause we need to use `await` for createTranslationconstIndexPage=async({params:{locale}})=>{// Make sure to use the correct namespace here.const{t}=awaitcreateTranslation(locale,'home');return(<div><h1>{t('greeting')}</h1></div>);};exportdefaultIndexPage;
Translate the About page
app/[locale]/about/page.tsx
123456789101112131415import{createTranslation}from'../../../i18n/server';// This should be async cause we need to use `await` for createTranslationconstAboutPage=async({params:{locale}})=>{// Make sure to use the correct namespace here.const{t}=awaitcreateTranslation(locale,'about');return(<div><h1>{t('aboutThisPage')}</h1></div>);};exportdefaultAboutPage;
With the codes above, we can now see the content translated.
Client-side translation
One issue in the previous example is that the links in the header do not update accordingly to the selected language.
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
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152'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{typeLocaleTypes, getOptions, locales}from'./settings';const runsOnServerSide =typeof window ==='undefined';// Initialize i18next for the client sidei18next.use(initReactI18next).use(LanguageDetector).use(resourcesToBackend((language: LocaleTypes, namespace:string)=>import(`./locales/${language}/${namespace}.json`),),).init({...getOptions(), lng:undefined,// detect the language on the client detection:{ order:['path'],}, preload: runsOnServerSide ? locales :[],});exportfunctionuseTranslation(lng: LocaleTypes, ns:string){const translator =useTransAlias(ns);const{i18n}= translator;// Run when content is 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: LocaleTypes){// 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]);}
Create the translations for the navigation links
We'll put the translations in the common namespace as they are shared common pages.
Now the links will update accordingly to the selected language
Handle default locale
This may vary based on the user's requirements, but I prefer not to include the default locale in the URL. I want localhost:3000 to be equivalent to localhost:3000/en, and when I visit localhost:3000/en, I want the /en in the URL to be automatically removed.
To achieve this, we need to do some URL rewriting and redirecting.
middleware.ts
12345678910111213141516171819202122232425262728293031323334353637383940414243444546import{NextResponse, NextRequest}from'next/server';import{fallbackLng, locales}from'./i18n/settings';exportfunctionmiddleware(request: NextRequest){// Check if there is any supported locale in the pathnameconst pathname = request.nextUrl.pathname;// Check if the default locale is in the pathnameif( pathname.startsWith(`/${fallbackLng}/`)|| pathname ===`/${fallbackLng}`){// e.g. incoming request is /en/about// The new URL is now /aboutreturn NextResponse.redirect(newURL( pathname.replace(`/${fallbackLng}`, pathname ===`/${fallbackLng}`?'/':'',), request.url,),);}const pathnameIsMissingLocale = locales.every( locale =>!pathname.startsWith(`/${locale}/`)&& pathname !==`/${locale}`,);if(pathnameIsMissingLocale){// We are on the default locale// Rewrite so Next.js understands// e.g. incoming request is /about// Tell Next.js it should pretend it's /en/aboutreturn NextResponse.rewrite(newURL(`/${fallbackLng}${pathname}`, request.url),);}}exportconst config ={// Do not run the middleware on the following paths matcher:'/((?!api|_next/static|_next/image|manifest.json|assets|favicon.ico).*)',};
NextJS is truly magical in making things just work
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).
Let's create a component that uses the translations above. We'll make this a server component to demonstrate one way of passing the locale.
components/SubscribeForm.tsx
1234567891011121314151617181920212223importReactfrom'react';import{createTranslation}from'../i18n/server';// pass the locale as a propconstSubscribeForm=async({locale})=>{const{t}=awaitcreateTranslation(locale,'newsletter');return(<sectionclassName="w-[350px]"><h3>{t('title')}</h3><h4>{t('subtitle')}</h4><formclassName="flex flex-col items-start"><inputplaceholder={t('form.firstName')}className="form-field"/><inputplaceholder={t('form.email')}className="form-field"/><buttonclassName="form-field">{t('form.action.signUp')}</button><buttonclassName="form-field">{t('form.action.cancel')}</button></form></section>);};exportdefaultSubscribeForm;
1234567891011121314151617importSubscribeFormfrom'../../components/SubscribeForm';import{createTranslation}from'../../i18n/server';constIndexPage=async({params:{locale}})=>{// Make sure to use the correct namespace here.const{t}=awaitcreateTranslation(locale,'home');return(<div><h1>{t('greeting')}</h1><hrclassName="my-4"/><SubscribeFormlocale={locale}/></div>);};exportdefaultIndexPage;
And now, we have this!
Built-in Formatting
It is very easy to format most of our data since i18next comes with a lot of utilities we can use.
Don't forget to render the component on the home page.
app/[locale]/page.tsx
1234567891011121314151617importBuiltInFormatsDemofrom'../../components/BuiltInFormatsDemo';import{createTranslation}from'../../i18n/server';constIndexPage=async({params:{locale}})=>{// Make sure to use the correct namespace here.const{t}=awaitcreateTranslation(locale,'home');return(<div><h1>{t('greeting')}</h1><hrclassName="my-4"/><BuiltInFormatsDemo/></div>);};exportdefaultIndexPage;
Internationalization is a complex requirement simplified in Nextjs due to the way applications are built using the framework. With the introduction of app directory, we need a different approach to handle i18n. Because of i18next it makes the task even simpler.