Webion

Internationalization with Next.js and next-intl

Guida pratica all’internazionalizzazione con Next.js e next-intl: architettura, middleware e traduzioni scalabili.

S
Saad Medhaton October 1, 2025
G15-compressed

Internationalization with Next.js and next-intl: how we implement it at Webion


Internationalization has become a baseline requirement in our projects. Within our applications, we use next-intl to orchestrate language handling, message loading, and user preferences. Below is an overview of the full architecture—from bootstrapping to the translation of reusable components.

Architecture overview

  • Next.js 15 with the App Router and hybrid server/client rendering.
  • next-intl for localization, middleware, and server-side APIs.
  • JSON catalogs, versioned per language, stored in apps/app/messages.
  • Middleware (apps/app/middleware.ts) that resolves the language starting from the incoming request.
  • Global provider in the layout to serve translations across the entire app.

Centralized configuration of next-intl


TYPESCRIPT
1// apps/app/i18n.ts
2import { getRequestConfig } from "next-intl/server";
3
4export const locales = ["en"] as const;
5export const defaultLocale = "en";
6export const localePrefix = "never";
7
8export default getRequestConfig(async ({ requestLocale }) => {
9 let locale = await requestLocale;
10
11 if (!locales.includes(locale)) {
12 locale = defaultLocale;
13 }
14
15 return {
16 locale,
17 messages: (await import(`./messages/${locale}.json`)).default,
18 };
19});


Key points:

  • getRequestConfig centralizes message loading for every request.
  • Translations are dynamically imported, enabling Next.js to create separate bundles for each language.
  • localePrefix = "never" keeps URLs clean (/dashboard instead of /en/dashboard) and delegates language resolution to the middleware.

Connect the plugin to the Next.js build process


TYPESCRIPT
1// apps/app/next.config.js
2import createIntlPlugin from "next-intl/plugin";
3
4const withNextIntl = createIntlPlugin("./i18n.ts");
5
6export default withNextIntl({
7 output: "standalone",
8});


The plugin registers next-intl parameters at build time, enables message extraction, and makes the use of getLocale / getMessages in server components completely transparent.

Middleware


TYPESCRIPT
1// apps/app/middleware.ts
2import { type NextRequest } from "next/server";
3import createMiddleware from "next-intl/middleware";
4import { locales, defaultLocale, localePrefix } from "./i18n";
5
6const intlMiddleware = createMiddleware({
7 locales,
8 defaultLocale,
9 localePrefix,
10});
11
12export default function middleware(req: NextRequest) {
13 return intlMiddleware(req);
14}


The middleware:

  • Determines the incoming language (from Accept-Language header, cookies, etc.).
  • Applies the default locale if the request specifies an unsupported language.

A global provider in the layout


TYPESCRIPT
1// apps/app/app/layout.tsx
2import { NextIntlClientProvider } from "next-intl";
3import { getLocale, getMessages } from "next-intl/server";
4
5export default async function RootLayout({ children }: { children: React.ReactNode }) {
6 const locale = await getLocale();
7 const messages = await getMessages();
8
9 return (
10 <html lang={locale} suppressHydrationWarning>
11 <ThemeLayout isMobile={await isMobileDevice()}>
12 <body>
13 <NextIntlClientProvider
14 locale={locale}
15 messages={messages}
16 >
17 <RootLayoutClient>
18 {children}
19 </RootLayoutClient>
20 </NextIntlClientProvider>
21 </body>
22 </ThemeLayout>
23 </html>
24 );
25}


I retrieve language information directly on the server side and inject it into the provider.
This way, any client component (even those in reusable packages) can call useTranslations without any additional boilerplate.

Structure message catalogs


JSON
1// apps/app/messages/en.json
2{
3 "dashboard": {
4 "machinesCount": "{count} machines",
5 "dateRange": {
6 "last-hour": "Last hour",
7 "week-to-date": "Week to date"
8 }
9 },
10 "agentCard": {
11 "selectedVariable_one": "1 selected variable",
12 "selectedVariable_other": "{count} selected variables"
13 }
14}


Adopted strategies:

  • Hierarchical key naming to reflect the functional domain (e.g., dashboard.stats.activeUsers), ensuring clarity and consistency across modules.
  • ICU-style placeholders ({count}) for numbers, strings, and dynamic values, enabling proper pluralization and variable interpolation.
  • Language-specific catalogs (en.json, it.json, etc.) replicate the same structure, so translations remain aligned and easy to maintain across locales.

Translations in client components


TYPESCRIPT
1// apps/app/app/[locale]/(auth)/login/page.tsx
2"use client";
3import { useTranslations } from "next-intl";
4
5export default function LoginPage() {
6 const t = useTranslations("login");
7
8 return (
9 <form>
10 <label>{t("emailLabel")}</label>
11 <input placeholder="name@example.com" />
12 <button type="submit">{t("loginButton")}</button>
13 </form>
14 );
15}


useTranslations accepts a namespace (here, "login") and returns a typed resolver (thanks to the literal type of the JSON object). For parameterized messages, you just need to pass an object, e.g. t("machinesCount", { count }).

Translations in server components and error boundaries


TYPESCRIPT
1// apps/app/app/not-found.tsx
2import { getTranslations } from "next-intl/server";
3
4export default async function NotFound() {
5 const t = await getTranslations("notFound");
6 return (
7 <section>
8 <h2>{t("title")}</h2>
9 <p>{t("description")}</p>
10 </section>
11 );
12}


getTranslations works in any server component or action, allowing me to localize error pages, metadata, or API responses without relying on client-side hooks.

Shared components and UI packages

Even a shared UI package (packages/ui/src/user/Component.tsx) can use useTranslations("user"). The provider in the main layout ensures that text resources are available regardless of where the component is mounted—essential in a monorepo with a reusable design system.

Adding a new language

  1. Create a message file in apps/app/messages/<locale>.json, replicating the existing structure.
  2. Update the locales array in apps/app/i18n.ts (e.g., ["en", "it"] as const).
  3. (Optional) Configure the preferred language in the middleware via cookies or custom headers.

Best practices we apply

  • Keep localePrefix = "never" to maintain short URLs; if localized paths are needed, the strategy can be changed without touching the components.
  • Avoid manual string concatenation—every variable phrase should live in the JSON catalog.

Conclusion

The combination of Next.js + next-intl allows centralized locale control, dynamic distribution of translations, and reuse across the entire monorepo. The JSON catalog structure and global provider make it easy to add new languages in the future.


Saad Medhat

La vecchia porta la sbarra