Internationalization in Next.JS
How to implement internationalization in Next.js
FRONTEND
savz
12 min read
Lets implement internationalization in Next.JS
Learn how to build a sample app in Next.js and level up your Next.js i18n skills to implement internationalization.
Next.js has built-in support for internationalized (i18n) routing since v10.0.0. You can provide a list of locales, the default locale, and domain-specific locales and Next.js will automatically handle the routing.
Next.js by default support two types of routing based on internationalization
Domain routing - example.com/blog, www.example.com/blog, example.fr/blog, example.nl/blog, example.nl/nl-BE/blog
Sub-path routing - /blog, /fr/blog, /nl-nl/blog
About Next JS
Next.js is a powerful frontend framework that offers seamless internationalization and translation capabilities. With Next.js, developers can easily build multilingual websites, ensuring that their content is accessible to a global audience. The framework provides robust tools and APIs for managing translations, allowing developers to organize and maintain their localized content efficiently. Whether you need to translate user interface elements, static content, or dynamic data, Next.js has you covered. The internationalization features in Next.js make it simple to serve different language versions of your site based on user preferences or location. Additionally, Next.js supports popular translation libraries, making it easy to integrate with existing localization workflows. With its comprehensive internationalization and translation capabilities, Next.js empowers developers to create inclusive and user-friendly web applications across the globe.
SETUP
Lets first create a nextjs app from scratch. We will add 2 flows in the app - login and registration based on button click from the main page
Use the command npx create-next-app@latest, and create the app


We'll use the below libraries for implementing internationalization
i18next
react-i18next
i18next-browser-languagedetector
Once the project is created, we will add the structure as shown in the app folder.
app
│ favicon.ico
│ globals.css
│ layout.tsx
│ page.tsx
│
├───components
│ SwitchLocale.tsx
│
└───[locale]
│ home.tsx
│ layout.tsx
│ page.tsx
│
├───login
│ page.tsx
│
└───registration
page.tsx
Let's add the project files
login/page.tsx
const Login = async ({ params }: { params: { locale: 'en' | 'es' } }) => {
return (
<div className='flex flex-col items-center justify-center px-6 py-8'>
<div className='w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700'>
<div className='p-6 space-y-4 md:space-y-6 sm:p-8'>
<form className='space-y-4 md:space-y-6'>
<div>
<label htmlFor='email' className='block mb-2 text-sm font-medium text-gray-900 dark:text-white'>Email</label>
<input type='email' name='email' id='email'
className='bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500'
placeholder='name@company.com' required />
</div>
<div>
<label htmlFor='password' className='block mb-2 text-sm font-medium text-gray-900 dark:text-white'>Password</label>
<input type='password' name='password' id='password' placeholder='••••••••'
className='bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500'
required />
</div>
<button type='submit'
className='w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800'
>Login </button>
<p className='text-sm font-light text-gray-500 dark:text-gray-400'>Don't have an account yet?<a href='#'>Sign up</a></p>
<button type='submit'
className='w-full text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800'>Back</button>
</form>
</div>
</div>
</div>
);
};
export default Login;
registration/page.tsx
const Registration = async ({ params }: { params: { locale: 'en' | 'es' } }) => {
return (
<div className='flex flex-col items-center justify-center px-6 py-8'>
<div className='w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700'>
<div className='p-6 space-y-4 md:space-y-6 sm:p-8'>
<form className='space-y-4 md:space-y-6'>
<div>
<label htmlFor='name' className='block mb-2 text-sm font-medium text-gray-900 dark:text-white'>Name</label>
<input type='name' name='name' id='name' placeholder='xyz'
className='bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500' required />
</div>
<div>
<label htmlFor='email' className='block mb-2 text-sm font-medium text-gray-900 dark:text-white'>Email</label>
<input type='email' name='email' id='email'
className='bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500'
placeholder='name@company.com' required />
</div>
<div>
<label htmlFor='password' className='block mb-2 text-sm font-medium text-gray-900 dark:text-white'>{t('login.password')}</label>
<input type='password' name='password' id='password' placeholder='••••••••'
className='bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500'
required />
</div>
<button type='submit'
className='w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800'>Register </button>
<button type='submit'
className='w-full text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800'>Back</button>
</form>
</div>
</div>
</div>
);
};
export default Registration ;
Setup i18n configuration for internationalization
Lets create a new folder called i18n in the root (outside app) and add the below files to set up the i18n configuration that will be used by our applciation for proper translations
i18n/settings.ts
import type {InitOptions} from 'i18next';
export const fallbackLng = 'en';
export const locales = [fallbackLng, 'es'] as const;
export type Locales = (typeof locales)[number];
export const defaultNS = 'common';
export function getOptions(lang = fallbackLng, ns = [defaultNS]): InitOptions {
return {
// debug: true, // Set to true to see console logs
supportedLngs: locales,
fallbackLng,
lng: lang,
fallbackNS: defaultNS,
defaultNS,
ns,
};
}
Lets add a backend file to lazy load the namsepace files
i18n/backend.ts
import { BackendModule, InitOptions, ReadCallback, Services } from "i18next";
const backend: BackendModule = {
type: "backend",
init: function (
services: Services,
backendOptions: object,
i18nextOptions: InitOptions<object>
): void {},
read: function (
language: string,
namespace: string,
callback: ReadCallback
): void {
import(`./locales/${language}/${namespace}.json`).then((object) => {
callback(null, object);
});
},
};
export default backend;
Lets add a server.ts file to import the translations for server side components
i18n/server.ts
import {initReactI18next} from 'react-i18next/initReactI18next';
import {getOptions, Locales} from './settings';
import backend from './backend';
import { createInstance } from 'i18next';
// Initialize the i18n instance
const initI18next = async (lang: Locales, ns: string[]) => {
const i18nInstance = createInstance();
await i18nInstance .use(initReactI18next) .use(backend) .init(getOptions(lang, ns));
return i18nInstance;
};
// It will accept the locale and namespace array for i18next to know what file to load
export async function buildTranslation(lang: Locales, ns: string[]) {
const i18nextInstance = await initI18next(lang, ns);
return {t: i18nextInstance.getFixedT(lang, Array.isArray(ns) ? ns[0] : ns), };
}
Please note that namespaces used throughout refer to localized translation files that we will maintain in the project.
Lets add few namespace files for login. The backend.ts will import the translation files as and when required
Lets add a common namespace file to hold all the translations for both login and registration flow. Lets add couple of languages - english and spanish to implement the internationalization feature
i18n/locales/en/common.json - This is to hold the english translations
{
"login": {
"email": "Email",
"password": "Password",
"remember_me": "Remember me",
"sign_up": "Sign up",
"no_account": "Don't have an account yet?",
"back": "Back",
"sign_in": "Log In",
"login": "Login"
},
"registration": {
"email": "Email",
"password": "Password",
"remember_me": "Remember me",
"sign_up": "Sign up",
"no_account": "Don't have an account yet?",
"back": "Back",
"sign_in": "Log In",
"login": "Login"
}
}
i18n/locales/es/common.json - This is to hold the spanish translations
{
"login": {
"email": "Correo electrónico",
"password": "Contraseña",
"remember_me": "Remember me",
"sign_up": "Inscribirse",
"no_account": "¿Aún no tienes una cuenta?",
"back": "Atrás",
"sign_in": "Acceso",
"login": "Acceso"
},
"registration": {
"name": "Nombre",
"email": "Correo electrónico",
"password": "Contraseña",
"register": "Registro",
"back": "Atrás"
}
}
That's all is required to set up the i18n configuration in the application.
Once i18n is wired in the app, and translation files are added, we need to make the changes in login and registration file to accept the translations based on locale
login/page.tsx
import { buildTranslation } from '@/i18n/server';
const Login = async ({ params }: { params: { locale: 'en' | 'es' } }) => {
const { t } = await buildTranslation(params.locale, ['common']);
return (
<div className='flex flex-col items-center justify-center px-6 py-8'>
<div className='w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700'>
<div className='p-6 space-y-4 md:space-y-6 sm:p-8'>
<form className='space-y-4 md:space-y-6'>
<div>
<label htmlFor='email' className='block mb-2 text-sm font-medium text-gray-900 dark:text-white'>{t('login.email')}</label>
<input type='email' name='email' id='email'
className='bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500'
placeholder='name@company.com' required />
</div>
<div>
<label htmlFor='password' className='block mb-2 text-sm font-medium text-gray-900 dark:text-white'>{t('login.password')}</label>
<input type='password' name='password' id='password' placeholder='••••••••'
className='bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500'
required />
</div>
<button type='submit'
className='w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800'
>{t('login.login')}</button>
<p className='text-sm font-light text-gray-500 dark:text-gray-400'>{t('login.no_account')} <a href='#'>Sign up</a></p>
<button type='submit'
className='w-full text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800'>{t('login.back')}</button>
</form>
</div>
</div>
</div>
);
};
export default Login;
login/registration.tsx
import { buildTranslation } from '@/i18n/server';
const Registration = async ({ params }: { params: { locale: 'en' | 'es' } }) => {
const { t } = await buildTranslation(params.locale, ['common']);
return (
<div className='flex flex-col items-center justify-center px-6 py-8'>
<div className='w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700'>
<div className='p-6 space-y-4 md:space-y-6 sm:p-8'>
<form className='space-y-4 md:space-y-6'>
<div>
<label htmlFor='name' className='block mb-2 text-sm font-medium text-gray-900 dark:text-white'>Name</label>
<input type='name' name='name' id='name' placeholder='xyz'
className='bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500' required />
</div>
<div>
<label htmlFor='email' className='block mb-2 text-sm font-medium text-gray-900 dark:text-white'>{t('registration.email')}</label>
<input type='email' name='email' id='email'
className='bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500'
placeholder='name@company.com' required />
</div>
<div>
<label htmlFor='password' className='block mb-2 text-sm font-medium text-gray-900 dark:text-white'>{t('registration.password')}</label>
<input type='password' name='password' id='password' placeholder='••••••••'
className='bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500'
required />
</div>
<button type='submit'
className='w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800'>{t('registration.register')}</button>
<button type='submit'
className='w-full text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800'>{t('registration.back')}</button>
</form>
</div>
</div>
</div>
);
};
export default Registration ;
We are all done with the login changes, you can refer the login flow to create namespace files for registration flow and add the corresponding translations. Lets add page.tsx and layout files to integrate the login and registration flows.
[locale]/page.tsx
import { HomeFlow } from './home';
const Home = async ({ params }: { params: { locale: 'en' | 'es' } }) => {
return (
<section className='bg-gray-50 dark:bg-gray-900'>
<div className='flex flex-col items-center px-6 py-8 mx-auto md:h-screen lg:py-0'>
<div className='w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700'>
<div className='p-6 space-y-4 md:space-y-6 sm:p-8'>
<h1 className='text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white'>
Choose action
</h1>
</div>
<HomeFlow />
</div>
</div>
</section>
);
};
export default Home;
[locale]/layout.tsx
import React from 'react';
const Layout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return (<>{children}</>);
};
export default Layout;
[locale]/home.tsx
'use client';
import { useParams, useRouter } from 'next/navigation';
export const HomeFlow = () => {
const router = useRouter();
const locale = useParams()?.locale;
const showLogin = () => {
router.replace(`/${locale}/login`);
};
const showRegister = () => {
router.replace(`/${locale}/registration`);
};
return (
<div className='items-center flex flex-col justify-center items-center px-6 py-8'>
<button
onClick={showLogin}
className='w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800'
>
Login
</button>
<button
onClick={showRegister}
className='w-full text-white bg-green-700 hover:bg-green-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800'
>
Register
</button>
</div>
);
};
Let's start the server now
npm run dev
Once started, let's navigate to http://localhost:3000/en. You should get the below page


Lets try to add a language selector now in the home page. Lets add a component called LangChooser.tsx
components/SwitchLocale.tsx
'use client';
import React, { useState } from 'react';
import {
useParams,
useRouter,
useSelectedLayoutSegments,
} from 'next/navigation';
const SwitchLocale = () => {
const router = useRouter();
const urlSegments = useSelectedLayoutSegments();
const lang = useParams()['locale'];
const [locale, setLocale] = useState(lang);
const handleLocaleChange = (val: string) => {
router.push(`/${val}/${urlSegments.join('/')}`);
};
return (
<div className='flex flex-col p-6 pt-10 bg-blue-500 bg-opacity-50'>
<div className='-mt-6 flex gap-1.5 flex flex-row justify-center items-center px-6 py-8'>
{' '}
<div>
<button className={locale == 'en' ? 'underline text-white' : 'no-underline'} onClick={() => handleLocaleChange('en')}>
{' '}
ENGLISH
</button>
</div>
<div>
<button className={locale == 'es' ? 'underline text-white' : 'no-underline'} onClick={() => handleLocaleChange('es')}>
{' '}
SPANISH
</button>
</div>
</div>
</div>
);
};
export default SwitchLocale;
Modify the layout file to include the component as below
[locale]/layout.tsx
import React from 'react';
import SwitchLocale from '../components/SwitchLocale';
const Layout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return (<><SwitchLocale/>{children}</>);
};
export default Layout;
Rerun the server now - npm run dev
Once started, let's navigate to http://localhost:3000/en. You should get the below page


Let's click on Login and Register Page and try to change the language to english and spanish. The pages will be translated accordingly
Thats it!!!!!!!!! We have enabled the translations implementation for server side components.
But although the login and registration pages are translated propely, we are still not able to do the translations for Login and Register button in the home page. The reason being the home component is a client component using 'use client'.
Lets check how to enable the translations for client side components.
Lets add a file named client.ts in i18n folder
i18n/client.ts
'use client';
import {useEffect} from 'react';
import i18next, {i18n} from 'i18next';
import {initReactI18next, useTranslation as useTransAlias} from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import {type Locales, getOptions, locales} from './settings';
import backend from './backend';
const runsOnServerSide = typeof window === 'undefined';
// Initialize i18next for the client side
i18next .use(initReactI18next).use(LanguageDetector) .use(backend )
.init({ ...getOptions(),
lng: undefined, // detect the language on the client
detection: {
order: ['path'],
},
preload: runsOnServerSide ? locales : [],
});
export function useTranslation(lng: Locales, ns: string[]) {
const translator = useTransAlias(ns);
const {i18n} = translator;
// Run when content is rendered on server side
if (runsOnServerSide && lng && i18n.resolvedLanguage !== lng) {
i18n.changeLanguage(lng);
} else {
useTransCustom(i18n, lng)
}
return translator;
}
function useTransCustom(i18n: i18n, lng: Locales) {
if (!lng || i18n.resolvedLanguage === lng) return;
i18n.changeLanguage(lng);
}
Lets modify home.tsx to add the translations
[locale]/home.tsx
'use client'
import { useTranslation } from "@/i18n/client";
import { useParams, useRouter } from "next/navigation";
export const HomeFlow = () => {
const router = useRouter();
const locale = useParams()?.locale;
const showLogin = () => {
router.replace(`/${locale}/login`)
}
const showRegister = () => {
router.replace(`/${locale}/register`)
}
const { t } = useTranslation(locale as 'en' | 'ms', ['login'])
return (
<div>
<button onClick={showLogin} type="button" className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">{t('login')}</button>
<button onClick={showRegister} type="button" className="focus:outline-none text-white bg-green-700 hover:bg-green-800 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-green-600 dark:hover:bg-green-700 dark:focus:ring-green-800">Register</button>
</div>
)
}
Rerun the server now - npm run dev
Once started, let's navigate to http://localhost:3000/en. You should be able to see the button label change on language change
That's all we have in this post. We have implemented both the server side and client side translations using i18next libraries