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 likehomePageHeroTitle
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:
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>
)
}
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
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!"
}
}
}
and this is for the fa.json
:
{
"home": {
"hero": {
"title": "سلام!"
}
}
}
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);
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,
};
});
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*']
};
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:
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>
);
}
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>
);
}
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 });
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>
);
}
Step Four: Split translations
First update the dictionaries file to something like this image:
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;
};
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,
};
});
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)