Internationalization (i18n) in Next.js
- nextjs
- react
- typescript
- i18n
- internationalization
- localization
- seo
- webdevelopment
- javascript
- softwarearchitecture
In Dark Mode Done Properly I covered how a seemingly small feature, theme switching, touches server rendering, hydration, and user preference storage in ways that are easy to underestimate. Internationalization has the same shape, except the blast radius is bigger. It touches routing, data fetching, date and currency formatting, SEO, and every piece of copy in the app.
This post is part of the React & Next.js Complete Guide series. It covers when i18n is actually worth building, how to structure it in the App Router without fighting the framework, and the pitfalls that turn a "just translate the strings" task into a multi-sprint effort.
I want to be direct about something before getting into implementation: i18n is one of the more expensive features you can add to a SaaS product, and it is also one of the easiest to add badly. Bolting translation keys onto an app that was never designed with locale in mind produces a codebase where every date, price, and pluralized string is a landmine. The fix is deciding early whether you need this at all, and if you do, building the locale boundary into your routing and data layer from day one rather than retrofitting it later. No library makes that decision for you.
Do you actually need i18n yet
Before touching next-intl or writing a translation pipeline, ask what problem you are solving. If you have one paying customer in Germany asking for a German UI, that is a request for one translated file, not a full i18n architecture. If your product is expanding into markets where English is a genuine adoption barrier, or a specific customer contract requires localized invoices and dates, that is a real signal.
The trap is building the general-purpose solution for a specific-purpose problem. A generic locale-routing system, a translation management pipeline, and RTL support cost real engineering time. They also add a permanent tax to every future page: every string has to go through a translation function, every date through a locale-aware formatter, and every PR touching copy needs a translation key added somewhere.
Deferring this is legitimate. A reasonable middle ground for an early-stage SaaS is to hardcode English, keep all user-facing strings in one place (even a flat object, not a full library) so a future extraction pass is mechanical, and use the Intl APIs for dates and numbers everywhere from the start. That single habit, always going through Intl.DateTimeFormat and Intl.NumberFormat instead of hand-rolled formatting, is the cheapest insurance against a painful i18n retrofit later, because locale-sensitive formatting will already be centralized when you need to make it dynamic.
// lib/format.ts
export function formatDate(date: Date, locale: string = "en-US"): string {
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
}
export function formatCurrency(
amountInCents: number,
currency: string,
locale: string = "en-US"
): string {
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
}).format(amountInCents / 100);
}
Routing strategy: locale prefixes vs domains
Once you decide i18n is warranted, the first real architectural decision is how locale shows up in the URL. Next.js App Router gives you a few workable patterns, and the choice affects SEO, caching, and how much of your routing tree you duplicate.
The most common approach is a locale segment at the root of the route tree: /en/dashboard, /de/dashboard, /fr/dashboard. This works cleanly with the App Router's nested layouts because you put a [locale] dynamic segment above everything else, and every route below it inherits the current locale through params.
app/
[locale]/
layout.tsx
page.tsx
dashboard/
page.tsx
settings/
page.tsx
layout.tsx
not-found.tsx
// app/[locale]/layout.tsx
import { notFound } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
const SUPPORTED_LOCALES = ["en", "de", "fr"] as const;
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
function isSupportedLocale(value: string): value is SupportedLocale {
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!isSupportedLocale(locale)) {
notFound();
}
const messages = await getMessages({ locale });
return (
<html lang={locale}>
<body>
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
The alternative is separate domains or subdomains per locale (de.example.com, example.fr). This is heavier to operate: separate DNS, certificates, and often separate deployments or edge configuration. But it gives you the cleanest SEO signal for regional targeting, and it lets you run genuinely different infrastructure per region if a market ever needs data residency. For most SaaS products, path-based locale prefixes are the right default. Reach for domain separation only when a market has a hard requirement, like legal data locality, that a path prefix cannot satisfy.
A middleware.ts at the project root typically handles locale detection and redirecting a bare /dashboard request to /en/dashboard based on the Accept-Language header or a stored cookie, so users never have to type the locale themselves.
// middleware.ts
import createMiddleware from "next-intl/middleware";
export default createMiddleware({
locales: ["en", "de", "fr"],
defaultLocale: "en",
localePrefix: "always",
});
export const config = {
matcher: ["/((?!api|_next|.*\\..*).*)"],
};
Structuring translations so they don't rot
The library choice, next-intl, react-i18next, or a hand-rolled solution, matters less than the discipline around how translation keys are organized. A single flat en.json with a thousand keys becomes unmaintainable fast: nobody can tell which keys are still used, and merge conflicts on that file become routine once more than one person touches copy in the same sprint.
Namespace keys by feature area, matching your route or component structure, rather than by page title or by developer intuition. This means when you delete a feature, you delete its translation file too, instead of leaving orphaned keys behind indefinitely.
// messages/en/billing.json
{
"invoiceTitle": "Invoice {number}",
"dueDate": "Due {date}",
"status": {
"paid": "Paid",
"overdue": "Overdue",
"pending": "Pending"
},
"itemCount": "{count, plural, one {# item} other {# items}}"
}
That last key is the detail teams underestimate: pluralization is a hard requirement in most languages, and the rules differ per language. English has two plural forms. Polish has three. Arabic has six. Hand-rolling count === 1 ? "item" : "items" works for English and silently produces wrong grammar for every other locale you add. ICU message format, which both next-intl and react-i18next support, encodes plural rules per locale so you write the rule once and the library picks the right branch based on the active locale's CJK/Slavic/Arabic plural categories.
Server Components can resolve translations at render time without shipping the translation library to the client for content that never changes after the page loads. Reserve NextIntlClientProvider and its client-side hooks for the interactive pieces, like a language switcher or a form with inline validation messages, so the bulk of your translated content stays server-rendered and out of the client bundle.
// app/[locale]/dashboard/page.tsx
import { getTranslations } from "next-intl/server";
export default async function DashboardPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "dashboard" });
return (
<div>
<h1>{t("title")}</h1>
<p>{t("welcomeMessage", { name: "Aman" })}</p>
</div>
);
}
SEO and metadata per locale
Translated content without correct SEO signaling actively hurts you: search engines need hreflang tags to know that /en/pricing and /de/pricing are alternate versions of the same page, otherwise they get treated as duplicate content competing against each other or, worse, only the default locale gets indexed at all.
Next.js's Metadata API supports this through alternates.languages, generated per route based on the current locale set.
// app/[locale]/pricing/page.tsx
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
return {
title: locale === "de" ? "Preise" : "Pricing",
alternates: {
canonical: `https://example.com/${locale}/pricing`,
languages: {
en: "https://example.com/en/pricing",
de: "https://example.com/de/pricing",
fr: "https://example.com/fr/pricing",
"x-default": "https://example.com/en/pricing",
},
},
};
}
The x-default entry matters and gets skipped often. It tells search engines and browsers which version to show a visitor whose locale does not match any of your supported languages, typically your default locale. Without it, locale-based redirects at the edge can produce inconsistent indexing behavior because crawlers do not carry the same Accept-Language context a real browser does.
Production pitfalls worth planning for upfront
A few issues show up specifically once i18n moves from a demo to a production SaaS handling real customer data across locales, and they are cheaper to design around now than to patch later.
Static generation and locale count multiply. If you statically generate pages per locale with generateStaticParams, a catalog page that took one build slot before now takes one per supported locale. This is usually fine at three or four locales; it becomes a real build-time cost once you support a dozen or more, and it is worth measuring before you commit to full static generation across every locale.
Right-to-left languages are not a CSS afterthought. Arabic and Hebrew need the dir="rtl" attribute on html, and any component built with hardcoded margin-left or text-align: left will visually break. Using CSS logical properties (margin-inline-start instead of margin-left) from the start avoids a separate RTL cleanup pass across the entire codebase later.
Locale-aware sorting is not string sorting. Array.prototype.sort() on strings uses code point order, which is wrong for locales with accented characters and different alphabetical conventions. Use Intl.Collator for any user-facing sort, like a customer list or a searchable table, that needs to feel correct to a native speaker of that locale.
const collator = new Intl.Collator("de");
const sorted = ["Müller", "Meyer", "Zimmer"].sort(collator.compare);
Translation keys drift from source. Once a product has more than one or two translators, English strings change in a PR without someone remembering to flag the corresponding key for retranslation. A missing-key fallback strategy (falling back to the default locale rather than rendering an empty string or a raw key) prevents this from becoming a visible bug, but it will not prevent stale translations from shipping. Treat translation freshness as a process problem, with a review step in your PR checklist, not something the library solves for you.
Key takeaways
- Confirm there is a real market or contractual signal before building full i18n. A one-off translated page is not the same problem as a multi-locale product.
- Route locale through the URL path (`/en/...`, `/de/...`) for most SaaS products; reserve domain-per-locale for hard data-residency requirements.
- Always format dates, numbers, and currency through `Intl.DateTimeFormat`, `Intl.NumberFormat`, and `Intl.Collator`, even before you support more than one locale, so the retrofit is mechanical later.
- Namespace translation keys by feature area and use ICU plural rules; hand-rolled pluralization logic breaks for most non-English languages.
- Resolve translations in Server Components wherever the content is static per request, and reserve client-side translation hooks for genuinely interactive UI.
- Set `alternates.languages` and `x-default` in metadata for every localized route, or search engines will treat your locales as duplicate or unindexed content.
Frequently asked questions
Does adding i18n to a Next.js app hurt performance?
Not inherently. The cost comes from shipping the translation library and message bundles to the client unnecessarily. If translations are resolved in Server Components, the client only pays for the interactive pieces that genuinely need locale-aware hooks, like a language switcher.
Should I use next-intl or react-i18next for a new Next.js App Router project?
`next-intl` is built specifically around the App Router's Server Component and layout model, so it tends to need less glue code for server-side translation resolution. `react-i18next` is more mature across a wider range of frameworks and has a larger plugin ecosystem, which matters if you are sharing translation infrastructure across a Next.js app and a separate non-Next.js client.
How do I handle a translation that is missing for a locale?
Configure a fallback locale in your library of choice so a missing key renders the default-locale string rather than the raw key or an empty string. Treat any fallback render as a signal to fix the source data, not as acceptable steady state, since silent fallbacks tend to accumulate unnoticed.
Do I need separate database columns for translated content?
For UI copy, no, that lives in translation files, not the database. For user-generated or admin-authored content that needs multiple language versions (like product descriptions in an e-commerce catalog), a separate translations table keyed by content id and locale is more maintainable than adding a column per language, since it does not require a schema migration every time you add a locale.
Can I detect locale automatically without asking the user?
Yes, using the `Accept-Language` header in middleware as a default, but always let the user override it explicitly and persist that choice in a cookie. Browser-reported language is a reasonable first guess, not a reliable signal of the language someone wants to read your product in.
Is it worth translating error messages and API responses, or just the UI?
If your API returns user-facing error strings directly (rather than error codes the frontend maps to copy), you need locale awareness on the backend too, which usually means passing the locale through request headers to your NestJS service and doing the same `Intl`-backed formatting there. Returning error codes from the API and translating them entirely in the frontend is simpler to maintain and keeps locale logic in one layer.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.