A while back, I wrote an article about adding type safety to translations in a TypeScript
project. In this article, we’ll build on that foundation by applying the same concept to a Next.js
app. With the help of Next.js
’s mature ecosystem, we can take things a step further and significantly enhance the developer experience.
Note: This guide is based onNext.js 15
and uses theApp Router
.
Objectives
Before diving into the implementation, let’s outline what we aim to achieve. These are the key goals:
-
Strict ICU Message Format validation: ICU-formatted strings (e.g.,
"The {fullName} is..."
or{count, plural, one {# item} other {# items}}
) should be fully type-safe. If a translation expects afullName
string or acount
number, the developer should be required to provide exactly that—nothing more, nothing less. This is the core of what type-safe translations are all about, and the ultimate goal of this article. -
Feature-based translation namespaces: Organize translations into multiple namespaces, where each namespace corresponds to a specific feature or group of related features. It’s also common to have a shared namespace for general-purpose keys (e.g.,
common
,auth
,billing
). This structure improves clarity and scalability. - Minimize the risk of human error: Translation systems are prone to mistakes—like renaming a key without updating its usages, or missing required changes in multiple places due to architectural complexity. Our goal is to catch such issues early—during development—by enforcing structure and type safety.
- Ensure full type safety: Type safety not only helps prevent runtime bugs, but also greatly enhances the developer experience by enabling auto-complete, inline validation, and faster debugging.
- Keep translation keys sorted automatically: When pull requests are merged, translation keys should be sorted consistently. This improves maintainability and reduces noise in version control diffs.
- Robust locale detection and fallback: Detect the user’s preferred locale from settings, fall back to their environment (e.g., browser), and finally fall back to a default locale. This final fallback is crucial to ensure functionality in cases where the user's environment locale is unsupported.
- Preview translated values during development: Developers should be able to see actual translation values in the code while working with them. This provides immediate feedback and ensures the correct keys and messages are used.
Set Up the Environment
The first step is to install the necessary packages and establish the project architecture. We'll begin by installing next-intl
. If you're using yarn
, you can run the command below — but feel free to use your preferred package manager, such as npm
or pnpm
:
yarn add -d next-intl
Next, we need to decide on the folder structure for our translations. There are several common patterns, such as {namespace}/{locale}.(json|yml)
or {locale}/{namespace}.(json|yml)
. In this article, we’ll use the i18n/{locale}/{namespace}.json
format. We’re using .json
because it’s supported out of the box by the library we’ve chosen. If you prefer .yml
, be aware that it may require additional configuration.
Here’s what the structure will look like:
i18n/
├── en-US/
│ ├── common.json
│ └── auth.json
└── fr-FR/
├── common.json
└── auth.json
It’s also common in Next.js
apps to include the locale in the URL, either as a subdomain or a path segment—for example:
-
{locale}.domain.com/page
-
domain.com/{locale}/page
However, for the purposes of this article, we won’t be using localized routes.
Setting Up next-intl
Before configuring next-intl
, let’s define a few helper types. These types will not only be reused across the app, but also help us declare the list of supported locales and namespaces in a single, centralized place.
export const locales = ["en-US", "fr-FR"] as const;
export type Locale = (typeof locales)[number];
export const DEFAULT_LOCALE: Locale = "en-US";
export const i18nNamespaces = ["common", "auth", "language"] as const;
export type I18nNamespace = (typeof i18nNamespaces)[number];
Next, create a file called request.ts
inside the i18n
directory. next-intl
will automatically pick up this file to load translations for a given locale. The file should export a default function that loads the appropriate translation messages. Here's how it looks:
import { getRequestConfig, RequestConfig } from "next-intl/server";
import { DEFAULT_LOCALE, i18nNamespaces, Locale } from "./i18n.types";
import { getUserLocale } from "./util";
export default getRequestConfig(async () => {
const locale = await getUserLocale();
const config: RequestConfig = {
locale,
messages: {},
};
for (const ns of i18nNamespaces) {
if (config.messages) config.messages[ns] = await loadTranslations(locale, ns);
}
return config;
});
const loadTranslations = async (locale: Locale, ns: string) => {
try {
return (await import(`./${locale}/${ns}.json`)).default;
} catch {
return (await import(`./${DEFAULT_LOCALE}/${ns}.json`)).default;
}
};
You can also export a formats
object from this file to define formatting rules for dateTime
, number
, and list
. However, we'll skip that for now as it’s outside the scope of this article.
You’ll notice that the implementation uses a getUserLocale
utility function. This function runs on the server and uses the following logic to determine the user’s locale:
- If the locale is stored in a cookie, it returns that.
-
If not, it checks whether the user’s preferred locale is stored in the database using the
getSettings
function. - If that’s not available either, it falls back to the locale inferred from the request headers—only if it’s a supported locale.
- As a final fallback, it returns the default locale.
Here’s the implementation of getUserLocale
:
import { cookies, headers } from "next/headers";
import { DEFAULT_LOCALE, Locale, locales } from "./i18n.types";
import { COOKIE_LOCALE } from "@/constants/cookie-names";
import { getSettings } from "@/actions/get-settings";
export const isValidLocale = (locale: string): locale is Locale => {
return locales.includes(locale as Locale);
};
export const getUserLocale = async (): Promise<Locale> => {
const _localeCookie = (await cookies()).get(COOKIE_LOCALE)?.value;
const localeFromCookie = locales.find((locale) => locale === _localeCookie);
const [localeFromHeader] = ((await headers())
.get("accept-language")
?.split(",")
.map((lang) => {
const [locale, q = "1"] = lang.trim().split(";q=");
return { locale, q: parseFloat(q) };
})
.filter(({ locale }) => isValidLocale(locale))
.sort((a, b) => b.q - a.q)
.map(({ locale }) => locale) ?? []) as Locale[];
return localeFromCookie || (await getSettings()).locale || localeFromHeader || DEFAULT_LOCALE;
};
In the code above, COOKIE_LOCALE
is a constant string representing the cookie name.getSettings
is a server function that fetches user settings from the database.
For demonstration purposes, here’s a mock version of it that returns static data:
"use server";
import { Locale } from "@/i18n/i18n.types";
type Settings = {
locale: Locale;
};
export const getSettings = async () =>
new Promise<Settings>((resolve) =>
resolve({
locale: "en-US",
})
);
Adding Type Safety
This is where the real magic happens. To enable type safety for translations, we need to configure next-intl
to watch the translation files for our default locale and generate corresponding type declarations. These type definitions ensure correctness and provide auto-completion throughout the app.
Since these files are auto-generated, there's no need to commit them to version control. Add the following pattern to your .gitignore
:
i18n/**/*.d.json.ts
Step 1: Configure next-intl
in next.config.ts
We’ll use the createNextIntlPlugin
utility and configure it to generate type declarations for the default locale (en-US
in this case):
import { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
import { i18nNamespaces } from "./i18n/i18n.types";
const nextConfig = {
... // your next config
} satisfies NextConfig;
const withNextIntl = createNextIntlPlugin({
experimental: {
createMessagesDeclaration: i18nNamespaces.map((ns) => `./i18n/en-US/${ns}.json`),
},
});
export default withNextIntl(nextConfig);
The createMessagesDeclaration
option tells next-intl
which files to scan and generate types for. Since all other locales should follow the same structure, it's enough to type only the default one.
⚠️ Make sure to enable"allowArbitraryExtensions": true
in thecompilerOptions
section of yourtsconfig.json
. This allowsTypeScript
to parse.d.json.ts
files correctly.
Step 2: Export Typed Messages
Now we need to export the messages object from our default locale in a way that retains its types. Here's an example:
import common from "@/i18n/en-US/common.json";
import auth from "@/i18n/en-US/auth.json";
import language from "@/i18n/en-US/language.json";
import { I18nNamespace } from "./i18n.types";
export const messages = {
common,
auth,
language,
} satisfies { [ns in I18nNamespace]: any };
This approach ensures that all defined namespaces are explicitly included. If you add a new namespace and forget to include it here, TypeScript
will flag it — giving you a clear and immediate signal to update the definition. This helps maintain consistency and improves the developer experience.
Step 3: Declare Global Types
Finally, declare the generated types globally so you can use them across your app:
import { Locale } from "../i18n/i18n.types";
import { messages } from "../i18n/message-types";
declare module "next-intl" {
interface AppConfig {
Locale: Locale;
Messages: typeof messages;
}
}
Consuming Translations in Components
To enable components to access translations, start by setting the lang
attribute on the element in your root
layout.tsx
. Additionally, wrap your page content with the NextIntlClientProvider
from next-intl
. Here's what the layout might look like:
import { getLocale } from "next-intl/server";
import { NextIntlClientProvider } from "next-intl";
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const locale = await getLocale();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
Once the provider is in place, you can use the useTranslations
hook in any component to access localized messages. For example:
import { useTranslations } from "next-intl";
export const MyComponent = () => {
const t = useTranslations();
return (
<>
<div>{t("auth.signUp")}</div>
{/* Other components or content can go here */}
</>
);
};
Add a Script to Sort Translation Keys
Now that we’ve achieved type-safe translations, let’s take it a step further by keeping our translation files clean and well-organized. One simple way to do this is by automatically sorting translation keys.
To accomplish this, we’ll write a script that reads each translation file, sorts its keys alphabetically, and saves the updated file. Here’s an example of such a script:
const fs = require("fs");
const path = require("path");
const i18nFolderPath = path.join(__dirname, "../i18n"); // Navigate up to find i18n
// Recursively sort JSON object keys
const sortObjectKeys = (obj) => {
if (Array.isArray(obj)) {
return obj.map(sortObjectKeys);
}
if (typeof obj === "object" && obj !== null) {
return Object.keys(obj)
.sort()
.reduce((sortedObj, key) => {
sortedObj[key] = sortObjectKeys(obj[key]);
return sortedObj;
}, {});
}
return obj;
};
const sortTranslationFiles = (folderPath) => {
fs.readdir(folderPath, (err, locales) => {
if (err) {
console.error("Error reading i18n folder:", err);
return;
}
locales
.filter((locale) => /[a-z]+\-[A-Z]+/g.test(locale))
.forEach((locale) => {
const localePath = path.join(folderPath, locale);
fs.readdir(localePath, (err, files) => {
if (err) {
console.error(`Error reading locale folder ${locale}:`, err);
return;
}
files
.filter((file) => file.endsWith(".json"))
.forEach((file) => {
const filePath = path.join(localePath, file);
fs.readFile(filePath, "utf8", (readErr, data) => {
if (readErr) {
console.error(`Error reading file ${filePath}:`, readErr);
return;
}
try {
const jsonData = JSON.parse(data);
const sortedData = sortObjectKeys(jsonData);
const sortedJson = JSON.stringify(sortedData, null, 2);
fs.writeFile(filePath, sortedJson + "\n", "utf8", (writeErr) => {
if (writeErr) {
console.error(`Error writing file ${filePath}:`, writeErr);
return;
}
console.log(`✅ Sorted keys in ${filePath}`);
});
} catch (parseErr) {
console.error(`Error parsing JSON in ${filePath}:`, parseErr);
}
});
});
});
});
});
};
// Run the script
sortTranslationFiles(i18nFolderPath);
Once the script is in place, simply add it as a step in your CI pipeline to ensure translations stay sorted automatically on every commit or pull request. You can also run it manually before committing.
Set Up the i18n-ally
VS Code Extension
This step is entirely optional, but highly recommended — it can significantly enhance the developer experience. Once configured correctly, the i18n-ally extension displays the actual translated values inline wherever the t
function is used.
For example, instead of seeing:
t('common.signUp')
you’ll see:
t("Sign Up")
This makes it much easier to understand and verify translations directly in your code.
After installing the extension, update your VS Code settings to include a configuration like this:
{
"i18n-ally.localesPaths": ["./i18n"],
"i18n-ally.enabledFrameworks": ["next-intl"],
"i18n-ally.namespace": true,
"i18n-ally.sortKeys": true,
"i18n-ally.pathMatcher": "{locale}/{namespace}.{ext}",
"i18n-ally.enabledParsers": ["json"],
"i18n-ally.keystyle": "nested",
"i18n-ally.indent": 2,
"i18n-ally.tabStyle": "space",
"i18n-ally.displayLanguage": "en-US",
"i18n-ally.sourceLanguage": "en-US",
}
You can also add "lokalise.i18n-ally"
to your extensions.json
to recommend it to your team.
That’s it — you're all set!
Thanks for reading. I hope this guide helped you build a more type-safe and developer-friendly internationalization setup in Next.js
.