NextJS Internationalization: A Complete Walkthrough

Localization
Ninad Pathak
08 Oct 2024

19 min. read

Contents

As a developer, it’s easy to focus on the language dearest to your heart and nearest to your home.

Having developed apps for years, I’ve learned that many companies focused solely on a single locale, failing to internationalize their apps.

Big mistake.

Let me show you how to internationalize your NextJS app to open it to billions of potential users who don’t speak your language. I’ll provide concrete NextJS examples of localization to get you started in no time.

I’ve written similar localization guides for React, Vue, and Svelte apps, so you might want to check those out too.

Back already? Great—let’s dive in!

Why internationalization matters more than you think

While only 20% of people speak English worldwide, over 50% of web pages are in English. statistics on the use of English online

Source: Statista

That’s almost 4 in 5 people who cannot access more than 50% of the content available online.

This statistic was enough for me to start internationalizing my applications.

To be honest, localization seemed like a chore to me. However, the more I looked into it, the more I noticed how it brought in new users and kept them engaged.

And it’s not just about translating the words, but about ensuring that people feel at ease using your app.

If you also want your users to enjoy, and hopefully, love your app, let’s dive into the intricacies of NextJS internationalization.

A step-by-step guide to NextJS internationalization (i18n)

Now that you’ve decided to make your app available in multiple languages, let’s go over the NextJS i18n process.

After you’re done, you’ll have a multilingual and multicultural NextJS app ready to go live.

Step 1: Create a new NextJS app

First, I’ll use the create-next-app to create a new NextJS app. Open up a terminal and enter:

npx create-next-app@latest nextjs-i18n-demo

Here's the terminal output:

creating a new NextJS app

I’ve selected No for pretty much all the options except for using the App Router. We’ll talk a bit more about routing types later. But once this command is done, it will add all the required files in the nextjs-i18n-demo directory to give you a basic NextJS app.

Once it’s ready, cd into the directory:

cd nextjs-i18n-demo

And the app is ready to be run as a test. So let’s do just that.

Step 2: Run the app locally and clean up the directory

The app won’t do much right now, but let’s run it to ensure we’ve set everything up correctly.

Run the below command while you’re inside the project directory.

npm run dev 

You should see a Welcome page on localhost:3000 if the app runs successfully.

If that worked, great! Let’s clean up the directory and make it ready for some actual code.

Delete everything in the app folder and create a simple page.js file with the following code:

// app/page.js

export default function Home() {
  return (
    <div>
      <h1>Welcome to Centus NextJS Internationalization Demo</h1>
      <p>This will be our basic app to demonstrate how i18n works.</p>
    </div>
  );
}

With this done, the index.js will be a simple page with a header and paragraph. If you now rerun the npm run dev command, your page should be updated with the new content.

an example of a NextJS app

I’m using the page.js file as the base for all our NextJS translations.

However, if you’re working with an existing app, you can follow the steps below while adjusting for file names and structure as per your app.

Step 3: Install internationalization libraries

Alright, let’s move on to other critical steps of Next.JS internationalization. The library you pick will need to support the routing system. Let me give you a quick brief.

What are Next.js routers and why we’re choosing next-intl

There are a few libraries like, next-intl, i18next, react-i18next that you could use. It depends on what routing system you use–App routing or Page routing.

Pages Router

  • Located in the pages/ directory.

  • Uses file-based routing where each file corresponds to a route.

  • Well-supported by libraries like i18next and next-i18next (ho

App Router

  • Introduced in Next.js 13, located in the app/ directory and recommended by NexJS devs

  • Supports advanced features like React Server Components and nested layouts.

  • Offers improved performance and scalability.

  • Perfect for the library we’re using — next-intl

Note: I’m using next-intl for two reasons: first, it’s extremely popular, and second, it’s easier to implement compared to many other libraries.

So let’s install the library and move to the next steps:

npm install next-intl 

We’re now ready to move to the next steps now.

Step 4: Configuring next-intl for translations and internationalization

This library works with the help of a middleware that handles the text translations during runtime. So, I’ll create a middleware.js file in the root of our project.

// middleware.js

import createMiddleware from 'next-intl/middleware';

export default createMiddleware({
  // A list of all locales that your application supports
  locales: ['en', 'es', 'fr'],
  // The default locale
  defaultLocale: 'en'
});

export const config = {
  // Skip all paths that should not be internationalized
  matcher: ['/((?!api|_next|.*\\..*).*)']
}; 

This file intercepts requests and determines the user's locale based on the URL or browser settings.

We also need a next.config.js file to set up the next-intl library. If you don’t have this file, create it in the root of your project and add the following code:

// next.config.js

const withNextIntl = require('next-intl/plugin')();

/** @type {import('next').NextConfig} */
const nextConfig = {};

module.exports = withNextIntl(nextConfig); 

If you rerun your server now, you should see a 404 error but you’ll also notice that the default URL auto-redirects to localhost:3000/en instead of just localhost:3000/.

That means you’re good to proceed!

Step 5: Create the translation files

I’ll now move to creating the NextJS translation files to work with our app. Let’s create a messages directory in the project root with JSON files for each locale.

mkdir messages
touch messages/en.json messages/es.json messages/fr.json 

Now let’s add translations to each of these JSON files.

To improve organization for for long-term maintainability, I’m nesting the translations under the “homepage” key. This will make it simpler to add new pages and translations without mixing them up later down the line.

messages/en.json

{
    "homepage": {
        "welcomeMessage": "Welcome to Centus NextJS Internationalization Demo",
        "description": "This will be our basic app to demonstrate how i18n works.",
        "changeLanguage": "Change Language"
    }
} 

messages/es.json

{
    "homepage": {
        "welcomeMessage": "Bienvenido al Demo de Internacionalización de NextJS de Centus",
        "description": "Esta será nuestra aplicación básica para demostrar cómo funciona i18n.",
        "changeLanguage": "Cambiar idioma"
    }
} 

messages/fr.json

{
    "homepage": {
        "welcomeMessage": "Bienvenue dans la démo d'internationalisation NextJS de Centus",
        "description": "Ceci sera notre application de base pour démontrer comment fonctionne i18n.",
        "changeLanguage": "Changer de langue"
    }
} 

Each of these messages directly corresponds to the individual text elements in our app.

I know it seems cumbersome, and that’s also why people tend to use a translation management system (TMS). But more on that later.

For now, let’s move on to the next step, that is, implementing these translations in our app.

Step 6: Using these translations in our app

Okay, you followed through and have all these app files floating around. But the app still shows a 404.

That’s because while we implemented a dynamic route, we haven’t actually implemented that in our app. So let’s do just that. Before you proceed, just ensure that your directory structure looks something like this:

a directory structure

Now, create a directory named [locale] inside your app directory and move all the files into it. The new directory structure should look something like this:

a new directory structure with a locale added to it

Note: Do not substitute the directory name [locale] with the actual locales. I'm using this name so that NextJS recognizes the individual locales as dynamic route parameters.

After this is done, your app will no longer show a 404. Instead, it’ll be back to displaying content from the page.js file.

Step 7: Update the layout.js and page.js files to use translations

We’ll now update the layout.js file to wrap our app with the NextIntlProvider. Replace the code in the existing layout.js file (or create a new file if it doesn’t exist):

// app/[locale]/layout.js

import { NextIntlClientProvider } from 'next-intl';
import { notFound } from 'next/navigation';

export const metadata = {
 title: 'Next.js',
 description: 'Generated by Next.js',
};

export default async function RootLayout({ children, params: { locale } }) {
 let messages;
 try {
   messages = (await import(`../../messages/${locale}.json`)).default;
 } catch (error) {
   notFound();
 }

 return (
   <html lang={locale}>
     <body>
       <NextIntlClientProvider locale={locale} messages={messages}>
         {children}
       </NextIntlClientProvider>
     </body>
   </html>
 );
}

And then, we also need to replace the hardcoded text in our code with the variables that next-intl can use for translating text.

// app/[locale]/page.js

import { useTranslations } from 'next-intl';
import LanguageSwitcher from '../../components/LanguageSwitcher';

export default function Home() {
  const t = useTranslations('homepage');

  return (
    <div>
      <h1>{t('welcomeMessage')}</h1>
      <p>{t('description')}</p>
      <LanguageSwitcher />
    </div>
  );
} 

Replace the code in our existing page.js file with the code above. You’ll also notice I’ve added the LanguageSwitcher tag. If we run the npm server now, the page will show an error because I haven’t implemented the language switcher yet.

So let’s do that!

Step 7: Implement the language switcher to use the selected locale

I’m going to add a simple dropdown with the list of locales that a user can pick from and our app will automatically switch the language.

Create a components folder in the root directory. Then, add a LanguageSwitcher.js file with the below code:

// components/LanguageSwitcher.js

'use client';

import { useRouter } from 'next/navigation';
import { useLocale } from 'next-intl';

export default function LanguageSwitcher() {
  const router = useRouter();
  const locale = useLocale();

  const handleChange = (e) => {
    const newLocale = e.target.value;
    router.push(`/${newLocale}`);
  };

  return (
    <select onChange={handleChange} value={locale}>
      <option value="en">English</option>
      <option value="es">Español</option>
      <option value="fr">Français</option>
    </select>
  );
} 

We're just creating a few drop-down options with the locales hardcoded in. Each locale has a value assigned that corresponds to the locale JSON file so when the URL is updated using router.push(`/${newLocale}, it will fetch the required text.

You can also dynamically generate them by pulling the locale names from the directory if you plan to add many languages in the future. However, for our purpose, this should suffice.

Now, if you’ve followed through all of this correctly, you should have a dropdown with the three languages listed.

a demo app with a language selection drop-down

Selecting any of these languages will add the locale to the URL, pull the required text, and update the page with the new text.

a localized web app with a language selection drop-down

You could also visit the locale-specific routes as below:

  • For English: localhost:3000/en
  • For Spanish: localhost:3000/es
  • For French: localhost:3000/fr

That’s it! You have a fully functional, internationalized NextJS app.

Advanced NextJS internationalization techniques

Now, are we all done? Absolutely not. What we've covered so far has been basic text translation.

It gets you up to speed.

But you must also handle some language nuances like plurals (some languages have six plural forms! ), date and currency formats, and more.

So let’s look at these advanced techniques one by one.

Handling numbers and currency formats

Not all countries follow the same number and currency formatting methods. For instance, if you had to write the number eight decimal three, here’s what it’d look like in the USA and France:

  • USA: 8.3
  • France: 8,3

Similarly, for currencies, in the US, we use the $ sign before the numbers, while France and Europe use the € sign after the numbers.

These differences may seem minor, but imagine selling a product on your app with a 5.600€ price tag in the US. People are bound to assume that the price is €5.6 instead of €5,600.

Luckily, we don’t have to handle this manually. Almost every Next JS localization library does this automatically.

Create a new file called components/FormattedNumber.js and add the following code:

// components/FormattedNumber.js
'use client';
import { useLocale } from 'next-intl';

export default function FormattedNumber({ value, style = 'decimal', currency }) {
  const locale = useLocale();
  
  const options = {
    style: style,
    ...(style === 'currency' && { currency }),
  };

  return new Intl.NumberFormat(locale, options).format(value);
} 

This single component can handle both regular numbers and currency formatting on the style property.

Now, I’ll import the above file and add a few lines with some numbers and currency values to format.

// app/[locale]/page.js
import { useTranslations } from 'next-intl';
import LanguageSwitcher from '../../components/LanguageSwitcher';
import FormattedNumber from '../../components/FormattedNumber';

export default function Home() {
  const t = useTranslations('homepage');

  return (
    <div>
      <h1>{t('welcomeMessage')}</h1>
      <p>{t('description')}</p>
      <p>A large number: <FormattedNumber value={1000000} /></p>
      <p>Price in USD: <FormattedNumber value={1234.56} style="currency" currency="USD" /></p>
      <p>Price in EUR: <FormattedNumber value={1234.56} style="currency" currency="EUR" /></p>
      <LanguageSwitcher />
    </div>
  );
} 

If you refresh the page now, you’ll see the large number and the currencies. Switching the locale will automatically format them.

a demo app with localized price and number formats

Alright, now let’s move to date formatting.

Date and time formatting

Countries also use different date formats, similar to numbers and currencies. Some have a DD/MM/YYYY format; others use an MM/DD/YYYY. Yet others use YY/MM/DD.

Showing the DD/MM/YYYY format to a person who’s used to seeing the month first will confuse them, and vice versa. So we need to pay special attention to date and time formatting.

Luckily, next-intl also handles local date and time formats.

Let’s create a file components/FormattedDateTime.js and add the following code:

// components/FormattedDateTime.js
'use client';
import { useLocale } from 'next-intl';

export default function FormattedDateTime({ value, format = 'date' }) {
  const locale = useLocale();
  
  const options = {
    date: { dateStyle: 'long' },
    time: { timeStyle: 'short' },
    dateTime: { dateStyle: 'long', timeStyle: 'short' },
  }[format];

  return new Intl.DateTimeFormat(locale, options).format(value);
} 

This will handle both the date and time formats automatically.

Let’s update our page.js file to display some dates and see how this affects what is displayed:

// app/[locale]/page.js
import { useTranslations } from 'next-intl';
import LanguageSwitcher from '../../components/LanguageSwitcher';
import FormattedDateTime from '../../components/FormattedDateTime';

export default function Home() {
  const t = useTranslations('homepage');
  const now = new Date();

  return (
    <div>
      <h1>{t('welcomeMessage')}</h1>
      <p>{t('description')}</p>
      <p>Today's date: <FormattedDateTime value={now} format="date" /></p>
      <p>Current time: <FormattedDateTime value={now} format="time" /></p>
      <p>Date and time: <FormattedDateTime value={now} format="dateTime" /></p>
      <LanguageSwitcher />
    </div>
  );
} 

Now, refresh your app page, and you should see the date and time formatted per the selected locale.

For example, "September 26, 2024" can be displayed as "26 septembre 2024" in French, and "3:30 PM" might be displayed as "15:30".

a demo app with localized date and time formats

Great! Now let’s proceed to plurals—a tricky aspect of internationalizing NextJS applications.

Handling pluralization

Pluralization often gets overlooked—either because people are unaware of the rules or it seems like a lot of additional effort.

But if there’s one thing to note, it’s that adding an 's' at the end of a word doesn’t solve the problem. For example, some languages have different forms for zero, one, two, few, many, and other quantities.

Let’s handle plurals for the three languages that we cover.

First, add a messageCount variable for each of the JSON files:

// messages/en.js

{
  "homepage": {
    "welcomeMessage": "Welcome to Centus NextJS Internationalization Demo",
    "description": "This will be our basic app to demonstrate how i18n-js works.",
    "changeLanguage": "Change Language",
    "messageCount": "{count, plural, =0{You have no messages} one{You have # message} other{You have # messages}}"
  }
}

// messages/es.js
{
  "homepage": {
    "welcomeMessage": "Bienvenido al Demo de Internacionalización de NextJS de Centus",
    "description": "Esta será nuestra aplicación básica para demostrar cómo funciona i18n.",
    "changeLanguage": "Cambiar idioma",
    "messageCount": "{count, plural, =0{No tienes mensajes} one{Tienes # mensaje} other{Tienes # mensajes}}"
  }
}


// messages/fr.js
{
    "homepage": {
      "welcomeMessage": "Bienvenue dans la démo d'internationalisation NextJS de Centus",
      "description": "Ceci sera notre application de base pour démontrer comment fonctionne i18n.",
      "changeLanguage": "Changer de langue",
      "messageCount": "{count, plural, =0{Vous n'avez aucun message} one{Vous avez # message} other{Vous avez # messages}}"
    }
  } 

Now create a component file components/PluralizedMessage.js that will handle pulling the required pluralized text from the translations JSON.

// components/PluralizedMessage.js
'use client';
import { useTranslations } from 'next-intl';

export default function PluralizedMessage({ count }) {
  const t = useTranslations('homepage');
  
  return <p>{t('messageCount', { count })}</p>;
} 

Finally, import this file to our page.js and display the different pieces of text:

// app/[locale]/page.js
import { useTranslations } from 'next-intl';
import LanguageSwitcher from '../../components/LanguageSwitcher';
import PluralizedMessage from '../../components/PluralizedMessage';


export default function Home() {
  const t = useTranslations('homepage');
  const now = new Date();

  return (
    <div>
      <h1>{t('welcomeMessage')}</h1>
      <p>{t('description')}</p>
      <PluralizedMessage count={0} />
      <PluralizedMessage count={1} />
      <PluralizedMessage count={5} />
      <LanguageSwitcher />
    </div>
  );
}

If you did everything correctly, you'll see this:

demo app with localized plurals

You can change the count variable that we’ve used in our page.js code to see how it affects the plurals. Some languages, like Ukrainian, have more forms of plurals. However, you can handle them just like we have handled English, Spanish, and French.

Here’s a snippet of how I’d handle Ukrainian plurals:

{
  "messageCount": "{count, plural, one{# яблуко} few{# яблук} many{# яблук} other{# яблука}}"
} 

Okay great! We’re close to completion. Now, we have to figure out how to work with right-to-left (RTL) languages.

Working with RTL languages

RTL languages are a different beast. Apart from just changing the text direction, you also have to change:

  • Text alignment (right-aligned instead of left-aligned)
  • Flow of UI elements (e.g., navigation bars typically on the right instead of left)
  • Directional icons (e.g., arrows pointing left for "next" instead of right)

I’ll be modifying a couple of things to make this all work together:

  1. I need to identify if a language is RTL or LTR, and based on that, change the layout of the page
  2. Change the layout.js file to pass the dir attribute based on the language direction
  3. Update the CSS to flip the layout and icons

Let’s start with creating a utils file to determine the language direction.

// utils/rtl.js
const rtlLanguages = ['ar', 'he', 'fa', 'ur']; // Add more as needed

export function isRTL(locale) {
  return rtlLanguages.includes(locale);
} 

Now, update the layout.js file to import the above file and use it to determine the dir attribute:

// app/[locale]/layout.js

import { NextIntlClientProvider } from 'next-intl';
import { notFound } from 'next/navigation';
import { isRTL } from '../../utils/rtl';

export const metadata = {
 title: 'Next.js',
 description: 'Generated by Next.js',
};

export default async function RootLayout({ children, params: { locale } }) {
 let messages;
 try {
   messages = (await import(`../../messages/${locale}.json`)).default;
 } catch (error) {
   notFound();
 }

 return (
  <html lang={locale} dir={isRTL(locale) ? 'rtl' : 'ltr'}>
     <body>
       <NextIntlClientProvider locale={locale} messages={messages}>
         {children}
       </NextIntlClientProvider>
     </body>
   </html>
 );
} 

And finally, we’ll change our CSS elements to use logical properties instead of left and right:

/* Before */
.someElement {
  margin-left: 10px;
  padding-right: 20px;
}

/* After */
.someElement {
  margin-inline-start: 10px;
  padding-inline-end: 20px;
} 

With this change, you ensure that the “start” and “end” are dynamically determined based on the language direction instead of setting margins and paddings on the “left” or “right” of the page.

How to translate JSON files for your NextJS app

Translating JSON files manually works fine for small projects. But if you’re translating larger apps into multiple languages, you need specialized tools to organize the process.

Here’s how to translate JSON files with the localization management platform Centus:

Start by signing up to Centus and creating a new project.

creating a new project in a TMS

Then, go to the Imports section and upload content files.

Centus supports not only JSON files, but also YAML, PHP, XLIFF, and many other file types, making it useful for all of your app localization projects. importing files to a TMS

The next step is to bring translators and editors to your project. inviting contributors to a TMS

You can manage user roles and permissions to ensure that everyone has access to the information they need.

After assembling your team of language experts, let them translate automatically segmented strings.

They can start by generating automatic translations and editing them in Centus Editor. This approach can reduce translation costs and time by up to 90%, which is critical for sim-ship releases. TMS editor

Encourage your team to communicate and share feedback, especially at the review stage. This way, you can avoid critical errors in the future. translators' comments in a TMS

To monitor the progress of your NextJS translation project, navigate to the Home panel and review key statistics. tracking project progress in a TMS

You can also use the notification panel to stay on top of the key steps and changes in the project. notifications in a TMS

When the translations are ready, incorporate them into your app following my advice above.

As you can see, Centus allows you to delegate the translation process to professionals and efficiently organize their cooperation.

While they are busy creating accurate translations, you can focus on other aspects of Next JS internationalization. Convenient? You bet!

Wrapping up

Now you know how to internationalize and localize your NextJS app.

As you grow, however, things do get a bit... complex.

That's where you need the localization management platform Centus.

Centus lets you bring dozens of translators and editors under one digital roof. The platform gives your language experts all the tools they need to create high-quality translations in record time.

Put simply, Centus makes NextJS localization a breeze. Try Centus now!

Get the week's best content!

By subscribing, you are agreeing to have your personal information managed in accordance with the terms of Centus Privacy Policy ->

Enjoyed the article?

Share it with your colleagues and partners 🤩