Internationalisation (i18n) in Next.js with next-intl

Internationalisation (i18n) in Next.js with next-intl

Featured on Hashnode

Launching a web application in multiple languages can seem daunting, but tools like next-intl make this process much easier. Having recently used next-intl in a bilingual Next.js project, I've seen firsthand how it simplifies adding internationalisation features to projects built with Next.js.

💡
Internationalisation, or i18n for short (the ‘18’ stands for the number of letters between the first ‘i’ and last ‘n’), is the process of designing a software application so that it can be adapted to various languages and cultural regions without requiring significant changes.

This article is not a tutorial; instead, it serves as a companion to next-intl's documentation, providing an introductory look into how next-intl helps implement necessary i18n features.

Note: This article is intended for individuals looking to use next-intl in a Next.js project with the App Router. It also assumes some basic knowledge about Next.js. If you want to learn more about Next.js, you can refer to their documentation.

How to get started with next-intl

If you want to use next-intl in your Next.js project, you can follow the basic set up instructions in the documentation. Once finished, the file structure of your Next.js project should look a bit like this:

├── messages (1)
│   ├── en.json
│   └── ...
├── next.config.mjs (2)
└── src
    ├── i18n.ts (3)
    ├── middleware.ts (4)
    └── app
        └── [locale]
            ├── layout.tsx (5)
            └── page.tsx (6)

You'll notice that all files inside /app are nested under /app/[locale]. This is required by Next.js so that the router can dynamically handle different locales in the route and forward the locale parameter to every layout and page.

💡
A locale is an identifier used to configure language and formatting preferences specific to the user’s needs. This often includes the user’s preferred language and can also pinpoint their specific geographic region. For example: en-GB for English in the United Kingdom, en-US for English in the United States, es for Spanish without a specific region.

My top recommendation is to implement basic internationalisation (i18n) in your project as soon as possible. This not only shapes your project's structure from the beginning but also streamlines the development process by integrating i18n into your architectural decisions early on. This approach helps you avoid costly and time-consuming rework that can occur if internationalisation is an afterthought. By setting up next-intl early, you'll gain a deep understanding of how internationalisation works within Next.js, guiding you to make informed decisions as your project progresses.

Routing and locale detection

In internationalisation, routing is important because it helps to organise URLs by different locales.

There are two ways to internationalise routes:

  1. Prefix-based routing: All routes have a prefix with a locale identifier (e.g., mywebsite.com/en/services, mywebsite.com/es/services).

  2. Domain-based routing: Localised content is provided from distinct domains or subdomains (e.g., mywebsite.com/services and misitioweb.es/services have separate domains).

The configuration of your middleware.ts file is critical and varies depending on the chosen routing strategy. The middleware detects the user's locale and redirects appropriately to display content in their preferred language. Once a locale is detected, next-intl saves it in the NEXT_LOCALE cookie for future reference.

For detailed instructions on configuring your middleware according to your routing strategy, please refer to this part of the official next-intl documentation.

Navigation

next-intl offers a replacement for the built-in navigation features from Next.js. This automatically manages all i18n routing for you and it's very simple to set up.

First, you need to choose the pathname strategy for your app:

  1. Shared pathnames: same pathname for all locales (e.g., /en/services and /es/services)

  2. Localised pathnames: distinct pathnames for each locale (e.g., /en/services and /es/servicios)

Next, create a navigation.ts file at the root of your project. Follow these instructions to set up this file based on your chosen pathname strategy.

For example, if your app uses shared pathnames, your navigation.ts file might look like this:

// navigation.ts

import {createSharedPathnamesNavigation} from 'next-intl/navigation';

export const locales = ['en', 'de'] as const; // this can be imported from elsewhere
export const localePrefix = 'always'; // Default

export const {Link, redirect, usePathname, useRouter} =
  createSharedPathnamesNavigation({locales, localePrefix});

Then, you will import Link, useRouter, usePathname and redirect directly from this navigation.ts file, rather than from Next.js. If you're collaborating on a project, make sure that everyone knows that they should import navigation elements from here. This guarantees that all language-specific routing is automatically managed by next-intl with no unexpected behaviour.

// An example of using next-intl navigation elements for shared pathnames
import { Link } from '@/navigation';

// When the user is on `/en`, the link will point to `/en/services`
<Link href="/services">Services</Link>

// You can override the `locale` to switch to another language
<Link href="/" locale="es">Switch to Spanish</Link>

Note: for localised pathnames, check how to correctly use next-intl navigation APIs, as they sometimes differ a bit from the standard Next.js behaviour. Please refer to this part of the next-intl documentation.

Messages

One of the primary challenges in building internationalised websites is configuring content in multiple languages. next-intl simplifies this by allowing you to manage all language-specific strings, known as "messages," in separate JSON files within a /messages directory.

You can organise these strings under different namespaces to keep related text grouped together. Here's how you might structure these files (in this example, we have two namespaces "Home" and "About"):

// en.json

{
  "Home": {
      "welcome": "Welcome to our website",
      "subtitle": "Explore our services and latest updates"
  },
  "About": {
      "title": "About us",
      "description": "Learn more about our mission, vision, and values"
  },
}

// es.json 

{
  "Home": {
      "welcome": "Bienvenido a nuestro sitio web"
      "subtitle": "Explora nuestros servicios y las últimas actualizaciones"
  },
  "About": {
      "title": "Sobre nosotros"
      "description": "Conoce más sobre nuestra misión, visión y valores"
  },
}

Messages can be rendered using the useTranslations hook from next-intl. For example, this is how we would display the title from the "About" namespace:

import { useTranslations } from 'next-intl';

function About() {
  // Here, we are loading the messages from the About namespace
  const t = useTranslations('About');
  return <h1>{t('title')}</h1>;
}

Messages can also adapt to dynamic content. For example, personalising a greeting with a user's name:

// en.json
"message": "Hello {name}"
// es.json
"message": "Hola {name}"
// this code would be inside of a component
t('message', { name: 'Lucy' }) // Hello Lucy | Hola Lucy

next-intl uses ICU message syntax, meaning that you can express all sorts of language nuances from within your JSON files. For example, managing plurals with the plural argument:

// en.json
"message": "{count, plural, =0 {You have no messages yet} =1 {You have 1 message} other {You have # messages}}"
// es.json
"message": "{count, plural, =0 {No tienes mensajes todavía} =1 {Tienes 1 mensaje} other {Tienes # mensajes}}"
t('message', {count: 5}) // You have 5 messages | Tienes 5 mensajes

You can also use the select argument to accommodate languages with gender-specific expressions. For example, you might adjust messages based on the user's gender:

"message": "{gender, select, female {Bienvenida} male {Bienvenido} other {Bienvenidx}} {name}"
t('message', {gender: 'female', name: 'Lucy'}) // Bienvenida Lucy

If you'd like to dive deeper into message configuration, check out this part of the next-intl documentation.

Rendering messages

next-intl recommends handling internationalisation on the server side for better performance. This way, messages stay on the server and don't require serialisation for the client side.

Rendering messages in server components is simple. You can choose to load all messages or the messages of a single namespace:

import { useTranslations } from 'next-intl';

function ComponentOne() {
  // Loads all messages from the "Welcome" namespace
  const t = useTranslations('Welcome');
  // We don't have to repeat the namespace here
  return <h1>{t('greeting')}</h1>;
}

function ComponentTwo() {
  // Here, we don't specify a namespace
  const t = useTranslations();
  return (
    <>
       // So, instead we specify the namespace before the key
       <h1>{t('Welcome.greeting')}</h1>
       <p>{t('About.title')}</p>
    </>
  )
}

next-intl's translation functions can be called within any server component at any level of the component tree without affecting performance. This means you don't have to unnecessarily pass props down, and you can handle string translations directly in each server component.

Client components

However, modern web applications often include interactive features and, as a result, client components. To maintain optimal performance, next-intl doesn't automatically provide messages to client components. But there are various ways to handle the translation of client components:

  1. Passing translated strings as props:
    Passing server-rendered translations to client components:

     /*
     Server component (Greeting)
     passing translations to a client component (InteractiveButton)
     */
     import { useTranslations } from 'next-intl';
     import InteractiveButton from './InteractiveButton';
    
     function Greeting() {
       const t = useTranslations('Greeting');
       return <InteractiveButton label={t('hello')} />;
     }
    
  2. Moving state to server side:
    Handle dynamic state such as pagination on the server side using page params, search params, cookies or database state.

  3. Using NextIntlClientProvider to load some messages
    In scenarios where client-specific translations are necessary, wrap components in NextIntlClientProvider and provide the relevant messages from chosen namespaces:

     import pick from 'lodash/pick';
     import {NextIntlClientProvider, useMessages} from 'next-intl';
     import ClientCounter from './ClientCounter';
    
     export default function Counter() {
       // Receive messages provided in `i18n.ts` …
       const messages = useMessages();
    
       return (
         <NextIntlClientProvider
           messages={
             // … and provide the relevant messages
             pick(messages, 'ClientCounter')
           }
         >
           <ClientCounter />
         </NextIntlClientProvider>
       );
     }
    
  4. Using NextIntlClientProvider to load all messages
    In the below example, the entire application is wrapped with NextIntlClientProvider to allow all client components to have access to all messages:

     import {NextIntlClientProvider, useMessages} from 'next-intl';
     import {notFound} from 'next/navigation';
    
     export default function LocaleLayout({children, params: {locale}}) {
       // ...
    
       // Receive messages provided in `i18n.ts`
       const messages = useMessages();
    
       return (
         <html lang={locale}>
           <body>
             <NextIntlClientProvider locale={locale} messages={messages}>
               {children}
             </NextIntlClientProvider>
           </body>
         </html>
       );
     }
    

The method you choose depends on how interactive your app is. If a part of your app is highly interactive, like a client dashboard, it's probably best to pass messages using NextIntlClientProvider. This allows you to make full use of all the features of next-intl within those components.

Asynchronous components

For dynamic scenarios involving data fetching, next-intl provides awaitable versions of their functions to handle translations in asynchronous components. For example, here we use getTranslations instead of useTranslations:

import {getTranslations} from 'next-intl/server';

export default async function ProfilePage() {
  const user = await fetchUser();
  const t = await getTranslations('ProfilePage');

  return (
    <PageLayout title={t('title', {username: user.name})}>
      <UserDetails user={user} />
    </PageLayout>
  );
}

Opting out of dynamic rendering

When you use functions like useTranslations in server components, your pages will automatically opt into dynamic rendering.

💡
Dynamic rendering means that routes are rendered for each user at request time, which can affect performance and response time.

This might not be the behaviour that you expect or want. For example, you might have a blog and want the content to be statically rendered.

To address this, next-intl have come up with a temporary API that can be used to distribute the locale that is received via params in layouts and pages for usage in all server components that are rendered as part of the request.

You'll need to include unstable_setRequestLocale to all layout.ts and page. files where you'd like to opt into static rendering:

import {unstable_setRequestLocale} from 'next-intl/server';

export default async function Blog({children, params: {locale}}) {
  unstable_setRequestLocale(locale);

  return (
    // ...
  );
}

This is a step that’s easy to forget but can lead to unexpected behaviour and bugs if forgotten.

While the label "unstable" may suggest caution, this API is stable in terms of functionality and reliability when used as directed. It's named in this way because it's supposed to act as a stopgap solution until Next.js potentially introduces a native API for accessing parts of the URL related to locales. You can read more about it here.

Conclusion

I hope you found this article interesting and that it provided a helpful insight into using next-intl for internationalisation in Next.js applications. My goal was to demonstrate some key features and show how they can improve the management of multilingual content in a simple and effective way.

I highly recommend diving into the official next-intl documentation for a more comprehensive understanding of its capabilities. The documentation is thorough and well-written, covering many features that I haven't touched upon.

It's also worth noting that the next-intl community is very active and supportive. I've personally had a great experience getting timely answers to my questions on GitHub, which has made implementing and troubleshooting much smoother.

If you've already experimented with next-intl, I'd love to hear about your experiences. Please share your thoughts and any insights in the comments below. Your feedback not only helps others but also enriches the community's collective knowledge.

Thank you for reading, and happy coding!

Further reading

Special thanks

Thank you to Jan Amann for creating this incredible library and for also taking the time to review this blog post before posting.