Next.js Internationalization for SaaS — How We Support Multiple Languages and Currencies

Every SaaS founder who lands their first international customer has the same realisation: the product works in English, but the customer needs it in German or Japanese or Arabic — and not just the words. The dates need the right format. The currency needs the right symbol and separator. The layout needs to flow right-to-left. And the language switcher needs to not break the URL. SaaS internationalization is all of that, not just a folder of translated strings.
We have shipped SaaS internationalization on enough products to have a standard setup we reach for every time. This post covers the full next-intl implementation in Next.js 16 App Router: locale routing, translations, RTL support, currency and date formatting, pluralization, and the translation workflow that scales beyond a single JSON file.
If your SaaS currently says "English only" in the footer — this post is for you.

When to Add SaaS Internationalization
Add i18n before you need it, not after. Retrofitting i18n into an existing Next.js app means restructuring every page under a [locale] segment, updating every hardcoded string, and testing every route. Adding it at the start takes two hours. Adding it later takes two weeks.
The trigger point is your first non-English support ticket. By the time a customer asks for translations, you should already have the routing and formatting infrastructure in place — even if you only ship the English messages at first.
next-intl Setup in App Router
1npm install next-intl1// next.config.ts
2import createNextIntlPlugin from "next-intl/plugin"
3
4const withNextIntl = createNextIntlPlugin()
5
6const nextConfig = {
7 output: "export", // static export support
8 trailingSlash: true,
9}
10
11export default withNextIntl(nextConfig)1// src/i18n/request.ts
2import { getRequestConfig } from "next-intl/server"
3import { routing } from "./routing"
4
5export default getRequestConfig(async ({ requestLocale }) => {
6 let locale = await requestLocale
7 if (!locale || !routing.locales.includes(locale as any)) {
8 locale = routing.defaultLocale
9 }
10
11 return {
12 locale,
13 messages: (await import(`../../messages/${locale}.json`)).default,
14 }
15})1// src/i18n/routing.ts
2import { defineRouting } from "next-intl/routing"
3
4export const routing = defineRouting({
5 locales: ["en", "de", "fr", "ar"],
6 defaultLocale: "en",
7 localePrefix: "as-needed",
8})
9
10// src/i18n/navigation.ts — locale-aware navigation helpers
11import { createNavigation } from "next-intl/navigation"
12import { routing } from "./routing"
13
14export const { Link, redirect, usePathname, useRouter, getPathname } =
15 createNavigation(routing)The localePrefix: "as-needed" option hides the locale segment for the default locale (/about instead of /en/about) while keeping it for others (/de/about). This keeps URLs clean for English users and explicit for everyone else.
Locale Detection and Middleware Routing
1// middleware.ts
2import createMiddleware from "next-intl/middleware"
3import { routing } from "./src/i18n/routing"
4
5export default createMiddleware(routing)
6
7export const config = {
8 matcher: ["/((?!api|_next|.*\\..*).*)"],
9}The middleware reads the Accept-Language header from the browser, matches it against your supported locales, and redirects the user to the correct locale path. If the user's browser sends Accept-Language: de-DE,de;q=0.9, they land on /de/about. If they have English preferences, they land on /about.
1// src/app/[locale]/layout.tsx
2import { NextIntlClientProvider } from "next-intl"
3import { getMessages, setRequestLocale } from "next-intl/server"
4import { routing } from "@/i18n/routing"
5import { notFound } from "next/navigation"
6
7export function generateStaticParams() {
8 return routing.locales.map((locale) => ({ locale }))
9}
10
11export default async function LocaleLayout({
12 children,
13 params,
14}: {
15 children: React.ReactNode
16 params: Promise<{ locale: string }>
17}) {
18 const { locale } = await params
19
20 if (!routing.locales.includes(locale as any)) {
21 notFound()
22 }
23
24 setRequestLocale(locale)
25 const messages = await getMessages()
26
27 return (
28 <html lang={locale} dir={locale === "ar" ? "rtl" : "ltr"}>
29 <body>
30 <NextIntlClientProvider messages={messages}>
31 {children}
32 </NextIntlClientProvider>
33 </body>
34 </html>
35 )
36}generateStaticParams tells Next.js which locale pages to build at compile time. Without it, your i18n routes render dynamically on every request, which defeats the purpose of a static export.

Translation File Structure
1// messages/en.json
2{
3 "common": {
4 "nav": {
5 "home": "Home",
6 "dashboard": "Dashboard",
7 "settings": "Settings"
8 },
9 "language": "Language"
10 },
11 "pricing": {
12 "title": "Pricing",
13 "monthly": "{price}/month",
14 "annual": "{price}/year"
15 },
16 "auth": {
17 "signIn": "Sign in",
18 "signUp": "Create account",
19 "welcomeBack": "Welcome back, {name}"
20 }
21}1// messages/de.json
2{
3 "common": {
4 "nav": {
5 "home": "Startseite",
6 "dashboard": "Dashboard",
7 "settings": "Einstellungen"
8 },
9 "language": "Sprache"
10 },
11 "pricing": {
12 "title": "Preise",
13 "monthly": "{price}/Monat",
14 "annual": "{price}/Jahr"
15 },
16 "auth": {
17 "signIn": "Anmelden",
18 "signUp": "Konto erstellen",
19 "welcomeBack": "Willkommen zurück, {name}"
20 }
21}Organise messages by feature namespace (common, pricing, auth, dashboard, billing). Flat files with namespaces are easier to maintain than a single flat map, and they let different teams own different namespaces.
Using Translations in Components
1"use client"
2
3import { useTranslations } from "next-intl"
4
5export function PricingCard({ price, interval }: { price: number; interval: "monthly" | "annual" }) {
6 const t = useTranslations("pricing")
7
8 return (
9 <div>
10 <h2>{t("title")}</h2>
11 <p>{t(interval, { price: `$${price}` })}</p>
12 </div>
13 )
14}For server components, import from next-intl/server:
1import { getTranslations } from "next-intl/server"
2
3export default async function DashboardPage() {
4 const t = await getTranslations("dashboard")
5
6 return <h1>{t("title")}</h1>
7}The getTranslations function works in server components and returns the same API as useTranslations on the client. Use it in layouts, pages, and server components to avoid passing translation functions down as props.
Handling Dynamic Content and Pluralization
ICU message syntax handles pluralization natively inside the JSON messages:
1{
2 "notifications": {
3 "unread": "You have {count, plural, =0 {no unread notifications} =1 {one unread notification} other {# unread notifications}}"
4 }
5}1t("unread", { count: notifications.length })
2// Output for count=0: "You have no unread notifications"
3// Output for count=1: "You have one unread notification"
4// Output for count=5: "You have 5 unread notifications"Pluralization rules vary by language. English has two forms (singular, plural). Arabic has six. French has two but handles zero differently. ICU messages handle this correctly per locale because the rules are embedded in the translation file, not the application code.
Currency Formatting Per Locale
Currency formatting is not just about the symbol — it is about symbol position, decimal separator, thousand separator, and minimum fraction digits:
1"use client"
2
3import { useFormatter } from "next-intl"
4
5export function PriceDisplay({ amount, currency }: { amount: number; currency: string }) {
6 const format = useFormatter()
7
8 return (
9 <span>
10 {format.number(amount, {
11 style: "currency",
12 currency,
13 })}
14 </span>
15 )
16}Output per locale:
| Locale | Amount | Currency | Formatted |
|---|---|---|---|
| en-US | 1499.9 | USD | $1,499.90 |
| de-DE | 1499.9 | EUR | 1.499,90 € |
| fr-FR | 1499.9 | EUR | 1 499,90 € |
| ja-JP | 1499.9 | JPY | ¥1,500 |
The Intl.NumberFormat API handles all of this natively. Never hardcode dollar signs or decimal separators. The browser knows how to format the user's locale — let it do its job.
Date and Time Formatting Per Locale
1import { useFormatter } from "next-intl"
2
3export function DateDisplay({ date }: { date: Date }) {
4 const format = useFormatter()
5
6 const relative = format.relativeTime(date)
7 const absolute = format.dateTime(date, {
8 year: "numeric",
9 month: "long",
10 day: "numeric",
11 })
12
13 return (
14 <time dateTime={date.toISOString()} title={absolute}>
15 {relative}
16 </time>
17 )
18}format.relativeTime returns "2 hours ago" or "in 3 days" in the user's locale. format.dateTime returns the absolute date formatted per locale conventions. Use the title attribute on <time> elements so the absolute date is available on hover.

RTL Layout Support for Arabic and Hebrew
Set the dir attribute on the <html> element based on the locale. Then use CSS logical properties so your layout flips automatically:
1/* Before — RTL breaks */
2.card {
3 margin-left: 1rem;
4 padding-right: 0.5rem;
5 border-left: 2px solid;
6 text-align: left;
7}
8
9/* After — RTL-safe */
10.card {
11 margin-inline-start: 0;
12 margin-inline-end: 1rem;
13 padding-inline-end: 0.5rem;
14 border-inline-start: 2px solid;
15 text-align: start;
16}margin-inline-start becomes margin-right in LTR and margin-left in RTL — automatically. The text-align: start aligns left in LTR and right in RTL.
Test RTL layouts with actual Arabic or Hebrew content, not lorem ipsum. Right-aligned text with English words looks wrong until you see it with Arabic script, where the letter shapes change based on position in the word.
Language Switcher Component
1"use client"
2
3import { usePathname, useRouter } from "@/i18n/navigation"
4import { useLocale } from "next-intl"
5import { useTransition } from "react"
6
7const locales = [
8 { code: "en", label: "English" },
9 { code: "de", label: "Deutsch" },
10 { code: "fr", label: "Français" },
11 { code: "ar", label: "العربية" },
12]
13
14export function LanguageSwitcher() {
15 const locale = useLocale()
16 const pathname = usePathname()
17 const router = useRouter()
18 const [isPending, startTransition] = useTransition()
19
20 const switchLocale = (next: string) => {
21 startTransition(() => {
22 router.replace(pathname, { locale: next })
23 })
24 }
25
26 return (
27 <select
28 value={locale}
29 onChange={(e) => switchLocale(e.target.value)}
30 disabled={isPending}
31 >
32 {locales.map((l) => (
33 <option key={l.code} value={l.code}>
34 {l.label}
35 </option>
36 ))}
37 </select>
38 )
39}The useRouter and usePathname from @/i18n/navigation (not next/navigation) are locale-aware wrappers provided by next-intl. They handle the locale prefix in the URL automatically — switching from English to German on /about navigates to /de/about without any manual path manipulation.
Translation Workflow for Growing Teams
A single messages/en.json file works until the SaaS has 400+ keys and three developers editing it simultaneously. At that point, split messages by namespace into separate files:
1messages/
2├── en/
3│ ├── common.json
4│ ├── auth.json
5│ ├── dashboard.json
6│ ├── billing.json
7│ └── pricing.json
8├── de/
9│ ├── common.json
10│ └── ...
11└── fr/
12 ├── common.json
13 └── ...Merge them in the request config:
1// src/i18n/request.ts
2export default getRequestConfig(async ({ requestLocale }) => {
3 const locale = await requestLocale
4
5 const [common, auth, dashboard, billing, pricing] = await Promise.all([
6 import(`../../messages/${locale}/common.json`),
7 import(`../../messages/${locale}/auth.json`),
8 import(`../../messages/${locale}/dashboard.json`),
9 import(`../../messages/${locale}/billing.json`),
10 import(`../../messages/${locale}/pricing.json`),
11 ])
12
13 return {
14 locale,
15 messages: {
16 ...common.default,
17 ...auth.default,
18 ...dashboard.default,
19 ...billing.default,
20 ...pricing.default,
21 },
22 }
23})For production teams, integrate a translation management system. Crowdin and i18nexus both offer CLI tools that sync your JSON files with their platform, letting translators work without touching code. Run the sync as a CI step so translations ship with every deploy.
Never use automated translation (Google Translate, DeepL) as your only translation layer for a paid product. Automated translation is fine for a marketing site. For billing emails, error messages, and legal text — pay a human translator.
SaaS internationalization is not a feature you bolt on after the product is built. It is an infrastructure decision you make early: next-intl for routing and translations, Intl.NumberFormat for currency and dates, logical CSS properties for RTL, and a translation file structure that scales with your team.
For more on the Next.js export configuration used here, see our guide on SaaS dashboard architecture which covers the same layout patterns. And for the authentication forms that need i18n support, our OAuth2 social login guide covers the auth flows.
The next-intl documentation covers the full API reference. The ICU message syntax guide explains pluralization rules for every language.
Adding i18n is not exciting engineering. It is the boring infrastructure work that makes your product feel local in a market where most competitors did not bother to translate the settings page. That gap is your advantage. Ship the translations.
Frequently Asked Questions
next-intl is the most widely adopted i18n library for Next.js App Router. It provides locale routing, server component support, ICU message syntax with pluralization, and type-safe translations. It is maintained by the community and works with Next.js 16 static exports.
Detect the locale in your root layout, set the dir attribute on the html element to 'rtl' for RTL locales, and use CSS logical properties (margin-inline-start instead of margin-left) so your layout flips automatically without duplicating stylesheets.
Use the Intl.NumberFormat API or next-intl's built-in format.number function. Pass the currency code and let the browser handle symbol placement, decimal separators, and grouping. For example, 1499.9 formats as '$1,499.90' in en-US and '1 499,90 $' in fr-FR.
Sub-path routing (/en/about, /fr/about) is simpler and works with a single deployment. Domain routing (en.example.com, fr.example.com) is better for SEO in specific countries but requires separate domain setup and complicates static generation. Start with sub-path routing.
Use a structured JSON file per locale (en.json, fr.json) with nested namespaces matching your app structure (common, auth, dashboard, billing). For team workflows, use a translation management system like Crowdin or i18nexus with automated sync via CLI.
