As your Next.js application grows, managing internationalization (i18n) becomes increasingly complex. A single en.json or ar.json file with thousands of lines is difficult to maintain, leads to frequent merge conflicts, and makes it hard for different teams to manage their own feature translations.
The solution is to split your translations into feature-based JSON files (namespaces) and compose them dynamically at request time.
In a large-scale application, a monolithic translation file causes several pain points:
auth, dashboard, settings).Instead of one giant file, we want a structure that reflects our features:
src/i18n/locales/
├── header/
│ ├── en.json
│ └── ar.json
├── signup/
│ ├── en.json
│ └── ar.json
└── project-report/
├── en.json
└── ar.jsonThen, at runtime, we "compose" these into a single nested object that next-intl understands:
{
"header": { ... },
"signup": { ... },
"projectReport": { ... }
}This allows you to use the standard useTranslations hook with namespaces:
const t = useTranslations("signup");
return <h1>{t("title")}</h1>;Pick a consistent convention. The most scalable approach is:
src/i18n/locales/[namespace]/[locale].json
Example:
src/i18n/locales/auth/en.jsonsrc/i18n/locales/auth/ar.jsonIn next-intl, the getRequestConfig function is where we define how messages are loaded. We can use Promise.all to load our namespaces in parallel for maximum performance.
// src/i18n/request.ts
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";
// Define your namespaces here
const namespaces = ["common", "auth", "dashboard", "settings", "errors"];
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
// Ensure the locale is valid
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
// Load all namespaces in parallel
const messages = {};
await Promise.all(
namespaces.map(async (namespace) => {
try {
const module = await import(`./locales/${namespace}/${locale}.json`);
messages[namespace] = module.default;
} catch (e) {
// Fallback to default locale if a specific namespace translation is missing
const fallback = await import(`./locales/${namespace}/${routing.defaultLocale}.json`);
messages[namespace] = fallback.default;
}
})
);
return {
locale,
messages
};
});By using Promise.all and dynamic imports, Next.js can fetch all required translation fragments simultaneously, minimizing the impact on Server-Side Rendering (SSR) time.
The try/catch block ensures that if a specific translation file is missing for the current locale, the application gracefully falls back to the default locale (e.g., English), preventing broken UI.
If you're using TypeScript, you can still get full autocomplete for your keys by defining a global IntlMessages interface based on your primary locale files.
project-report instead of projectReport for folder names to stay consistent with web standards.namespaces array in request.ts by reading the directory names.Splitting your locales into namespaces is a game-changer for large Next.js projects. It keeps your codebase organized, reduces friction in team environments, and ensures your internationalization strategy can scale alongside your product.
More articles you might find interesting
Master Next.js Multi-Zones to split large applications into independent micro-frontends. Learn implementation patterns, routing strategies, and best practices for enterprise-scale architecture.
Discover why using array indices as keys in React can lead to performance issues and bugs, and learn the best practices for proper list rendering.
Learn how to implement recursion in React components to manage nested radio button groups using JavaScript's Map object for efficient state management.