DEV Community

Cover image for Split your translations in `next-intl` in a nice way!
Hosein Pouyanmehr
Hosein Pouyanmehr

Posted on • Updated on

Split your translations in `next-intl` in a nice way!

TL;DR
You can install next-intl-split instead of reading this article, it is a tiny package that can save you some time.

What is this post about?

In a Next.js application, translating your content into different languages can be easily done using next-intl. However, after a while, you may find the translation files too long and hard to maintain and update. In this post, we'll look at an approach to keep the translation files modular and clean and put them into multiple JSON files. You can also start reading from this section if you know how to install and set up next-intl.

Table of Contents

Prerequisite

You need to understand next.js and i18n concepts such as app router and locale before reading this article. Also, having experience with next-intl is necessary, if you don't know how it works, please check its documentation first. I will use TypeScript in this project, but you can follow the steps for a JavaScript project, just ignore the types.

How the solution is going to work?

If you have tried next-intl before, as your application grows, you can find it difficult to keep your translation JSON files clean. Although the next-intl provides a built-in solution for splitting and separating your translations, I found that a bit hard to work with as you may end up with duplicated names or long prefixes for simple keys. So here are some downsides of the next-intl splitting approach:

  • You may not be able to use namespace as before.
  • You may need to prefix simple keys like title in a way like homePageHeroTitle to keep it unique
  • You have to import each translation file individually in the messages loader which can be tedious.

We're going to create some utilities and I'll explain them to you. Using these utilities (or the next-intl-split package) helps you to keep the main functionality and benefits of next-intl as well as separate your translation files properly into multiple smaller and cleaner files.

Using this approach, we're going to create folders that end with an index.json translation file in them (like /src/i18n/dictionaries/en/home/hero/index.json). The loadI18nTranslations function will load them all in one go and merge them so you can use namespace and also use the benefits of dot notation.

Implementation

We're going to create a next.js and next-inlt app first and then implement the utility in the last steps.

Step One: Creating a Next.js app

Run npx create-next-app command in your desired path to create a new next.js app.

here is my config for installation:

A screenshot of installation config for next.js

After installation, I'll clean useless files and try to keep the app as short as possible.

  • I've deleted the public folder
  • Fav Icon in /src/app/
  • All css styles in /src/app/globals.css except for @ imports of tailwind.
  • I've also changed the /src/app/page.tsx file to
export default function Home() {
  return (
    <main>
      <h1>Hello!</h1>
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

Now we're done with the Next.js setup.

Step Two: Adding next-intl and Config it

To add next-int run the following command:

npm i next-intl
Enter fullscreen mode Exit fullscreen mode

Note: I'm goin to follow the with-i18n-routing approach of next-intl. Feel free to follow your preferred one. The solution provided in this for article splitting will work with all approaches.

Then, create at least two locale translation files. In this step, we'll follow the next-intl structure to properly set it up. So, I'm gonna create an i18n folder and another dictionaries folder in it. After that, I'll add en.json and fa.json for English and Persian translations.

So this is the path: /src/i18n/dictionaries/ and this is the content for en.json:

{
  "home": {
    "hero": {
      "title": "Hello!"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

and this is for the fa.json:

{
  "home": {
    "hero": {
      "title": "سلام!"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

We're done with the translations, now we need to update the next.config.mjs file (or next.config.js file) to use next-intl plugin. This is the updated content for next.config.mjs:

import createNextIntlPlugin from 'next-intl/plugin';

const withNextIntl = createNextIntlPlugin(
  './src/i18n/i18n.ts'
);

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

export default withNextIntl(nextConfig);
Enter fullscreen mode Exit fullscreen mode

Note: I've changed the default path for the i18n.ts file and for that, I did add the new path in the createNextIntlPlugin function. Next, I'm going to add an i18n.ts file in the /src/i18n/ path. You can follow the next-intl documentation for changing or keeping the default paths.

After updating the next config file, Add the following utility to the /src/i18n/i18n.ts module. (You can put this file in /src/app as well, If you did, please keep it in mind to remove the path in the next.config.mjs file)

This will be the content for i18n.ts module:

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

export const locales = ['en', 'fa'];

export default getRequestConfig(async ({ locale }) => {
  if (!locales.includes(locale as any)) notFound();

  return {
    messages: (await import(`./dictionaries/${locale}.json`)).default,
  };
});
Enter fullscreen mode Exit fullscreen mode

Here, we read the content of the JSON files based on the user locale and return the proper messages object. Also, I've exported the locales for further use.

After adding i18n.ts, We'll add a middleware to catch and redirect user properly in routes. Add this file in /src/middleware.ts. It should be in this path and you cannot customize the path for this one.

import createMiddleware from 'next-intl/middleware';

import { locales } from '@/i18n/i18n';

export default createMiddleware({
  locales,
  defaultLocale: 'en'
});

export const config = {
  matcher: ['/', '/(en|fa)/:path*']
};
Enter fullscreen mode Exit fullscreen mode

I imported the previously defined locales array to have a single source of truth for that.

Now we just need to change the app router structure from /src/app/... to /src/app/[locale]/.... This help us to have control over the app locale from the very top level. You can see the difference in this picture:

A screenshot of changing the app router folder structure

Then, add the Provider to the /src/app/[locale]/layout.tsx. This will provide the messages all over the application. So the layout module will change to something like this:

import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';

import './globals.css';

export const metadata = {
  title: 'Next Intl Split',
};

export default async function RootLayout({
  children,
  params: { locale },
}: {
  children: React.ReactNode;
  params: { locale: string };
}) {
  const messages = await getMessages();

  return (
    <html dir={locale === 'en' ? 'ltr' : 'rtl'} lang={locale}>
      <body>
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

And finally, we can use our dictionaries in /src/app/[locale]/page.tsx like this:

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

export default async function Home() {
  const currentLocale = await getLocale();
  const translate = await getTranslations({
    locale: currentLocale,
    namespace: 'home.hero',
  });

  return (
    <main>
      <h1>{translate('title')}</h1>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step Three: Adding navigation

To navigate properly between routes of your application, add the following utility in the /src/i18n/navigation.ts.

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

import { locales } from '@/i18n/i18n';

export const { Link, redirect, usePathname, useRouter } =
  createSharedPathnamesNavigation({ locales });
Enter fullscreen mode Exit fullscreen mode

Now lets add some buttons to the home page to be able to change the locale. This is the updated page.tsx component content:

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

import { locales } from '@/i18n/i18n';
import { Link } from '@/i18n/navigation';

export default async function Home() {
  const currentLocale = await getLocale();
  const translate = await getTranslations({
    locale: currentLocale,
    namespace: 'home.hero',
  });

  return (
    <main>
      <h1>{translate('title')}</h1>
      <div className='flex gap-2'>
        {locales.map((locale) => (
          <Link
            className={`px-2 py-1 ${
              locale === currentLocale ? 'bg-slate-300' : 'border-2'
            }`}
            key={locale}
            href='/'
            locale={locale}
          >
            {locale}
          </Link>
        ))}
      </div>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step Four: Split translations

First update the dictionaries file to something like this image:

A screenshot of changing the dictionaries path in i18n

Please keep this in mind that you can separate your translations in your preferred way. Just be sure each folder end up with an index.json file.

Then add the following utility in /src/i18n/loader.ts or you can install its package by npm i next-intl-split

import fs from 'fs';
import path from 'path';

const addNestedProperty = (obj: { [key: string]: any }, keys: string[]) => {
  let current = obj;

  for (const key of keys) {
    if (!current[key]) {
      current[key] = {};
    }
    current = current[key];
  }

  return { obj, lastKey: current };
};

export const loadI18nTranslations = (
  dictionariesPath: string,
  locale: string
) => {
  const relativePath = dictionariesPath + locale;
  const absolutePath = path.join(process.cwd(), relativePath);

  let translations = {};

  try {
    const files = fs.readdirSync(absolutePath, { recursive: true });
    files.forEach((file) => {
      if (typeof file === 'string' && file.endsWith('.json')) {
        const fileParents = file
          .split(path.sep)
          .filter((parent) => parent !== 'index.json');
        const filePath = path.join(absolutePath, file);
        const fileTranslations = JSON.parse(fs.readFileSync(filePath, 'utf-8'));

        // Object {}
        translations = {
          ...translations,
        };

        const { lastKey } = addNestedProperty(translations, fileParents);

        Object.assign(lastKey, fileTranslations);
      }
    });
  } catch (error) {
    console.error(
      'The following error occured in loader in next-intl-split.',
      error
    );
  }

  return translations;
};
Enter fullscreen mode Exit fullscreen mode

Explanation: The above code look for the index.json files in the provided path based on app locale. At the end, it'll merge the json translation files and provide a single JSON file.

After creating this loader (or installing the package using next-intl-split command) in the /src/i18n/i18n.ts utility, implement this changes:

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

// import { loadI18nTranslations } from 'next-intl-split'; // Use this if you've installed the package
import { loadI18nTranslations } from '@/i18n/loader';

export const locales = ['en', 'fa'];

export default getRequestConfig(async ({ locale }) => {
  if (!locales.includes(locale as any)) notFound();

  const messages = loadI18nTranslations('/src/i18n/dictionaries/', locale);

  return {
    messages,
  };
});
Enter fullscreen mode Exit fullscreen mode

NOTE: The path provided to the loadI18nTranslations utility must be absolute path to the dictionaries folder so pay attention to that if you have issues in detecting the translations. (Also you can use relative path but keep in mind to start from the source folder ./src/i18n/dictionaries/ in my case)

Conclusion

Congrats! You're now able to separate your translations as you want. The above utility help you to have translation split ability in addition to all next-intl benefits.
Also, if you use translations frequently, you can install next-intl-split to avoid repetition and save time.

Top comments (0)