SaaS Internationalization: i18n Architecture, Currency Formatting, RTL, and Translation Workflows
Build production-ready SaaS internationalization: i18n architecture with next-intl, currency and date formatting, RTL layout support, and scalable translation workflows with Crowdin.
Internationalization is one of those features that's cheap to do right from the start and expensive to retrofit. Adding i18n to a mature codebase means touching thousands of hardcoded strings, discovering date formatting assumptions baked into business logic, and realizing your UI breaks completely in RTL languages.
This post covers the architecture decisions and implementation patterns that make i18n a first-class concern from day one.
Architecture: next-intl for Next.js
next-intl is the best-maintained i18n library for Next.js App Router. It supports Server Components, has TypeScript-aware message keys, and handles plural forms, ordinals, and rich text.
npm install next-intl
File Structure
messages/
en.json ← English (source language)
es.json ← Spanish
de.json ← German
ar.json ← Arabic (RTL)
ja.json ← Japanese
src/
i18n.ts ← Configuration
middleware.ts ← Locale routing
Configuration
// src/i18n.ts
import { getRequestConfig } from "next-intl/server";
import { notFound } from "next/navigation";
export const locales = ["en", "es", "de", "ar", "ja", "pt", "fr"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";
// RTL languages
export const rtlLocales: Locale[] = ["ar"];
export function isRTL(locale: Locale): boolean {
return rtlLocales.includes(locale);
}
export default getRequestConfig(async ({ locale }) => {
if (!locales.includes(locale as Locale)) notFound();
return {
messages: (await import(`../messages/${locale}.json`)).default,
timeZone: "UTC", // Override per-user in session
now: new Date(),
};
});
// middleware.ts
import createMiddleware from "next-intl/middleware";
import { locales, defaultLocale } from "./src/i18n";
export default createMiddleware({
locales,
defaultLocale,
// /en/dashboard → /dashboard (default locale URL-clean)
localePrefix: "as-needed",
// Detect from Accept-Language header and cookie
localeDetection: true,
});
export const config = {
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};
Message Files
// messages/en.json
{
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"loading": "Loading...",
"error": "Something went wrong"
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome back, {name}!",
"lastLogin": "Last login: {date, date, medium}"
},
"billing": {
"planLabel": "Your plan: {plan}",
"usage": "Used {count, number} of {total, number} {count, plural, one {seat} other {seats}}",
"invoice": {
"title": "Invoice #{number}",
"total": "Total: {amount}"
}
},
"errors": {
"required": "{field} is required",
"tooShort": "{field} must be at least {min} characters",
"invalidEmail": "Please enter a valid email address"
}
}
// messages/ar.json (Arabic — RTL)
{
"common": {
"save": "حفظ",
"cancel": "إلغاء",
"delete": "حذف",
"loading": "جار التحميل...",
"error": "حدث خطأ ما"
},
"dashboard": {
"title": "لوحة التحكم",
"welcome": "مرحباً بك مجدداً، {name}!",
"lastLogin": "آخر تسجيل دخول: {date, date, medium}"
},
"billing": {
"planLabel": "خطتك: {plan}",
"usage": "استخدمت {count, number} من {total, number} {count, plural, one {مقعد} other {مقاعد}}",
"invoice": {
"title": "فاتورة #{number}",
"total": "المجموع: {amount}"
}
}
}
Using Translations in Components
// Server Component
import { useTranslations } from "next-intl";
export default function DashboardPage() {
const t = useTranslations("dashboard");
return (
<div>
<h1>{t("title")}</h1>
<p>{t("welcome", { name: "Arjun" })}</p>
</div>
);
}
// Client Component
"use client";
import { useTranslations, useFormatter } from "next-intl";
export function BillingUsage({ used, total }: { used: number; total: number }) {
const t = useTranslations("billing");
return (
<p>{t("usage", { count: used, total })}</p>
// en: "Used 18 of 25 seats"
// ar: "استخدمت 18 من 25 مقاعد"
);
}
Currency and Number Formatting
Never hardcode currency symbols or formatting. The Intl.NumberFormat API handles all locale-specific formatting:
// src/lib/formatters.ts
import { useFormatter, useLocale } from "next-intl";
// Server-side formatting (no hooks)
export function formatCurrency(
amountCents: number,
currency: string,
locale: string
): string {
return new Intl.NumberFormat(locale, {
style: "currency",
currency: currency.toUpperCase(),
// Automatically handles:
// en-US: $1,234.56
// de-DE: 1.234,56 €
// ja-JP: ¥1,235 (no decimals)
// ar-SA: ١٬٢٣٤٫٥٦ ر.س. (Arabic numerals, RTL)
}).format(amountCents / 100);
}
// Examples:
// formatCurrency(123456, "USD", "en-US") → "$1,234.56"
// formatCurrency(123456, "EUR", "de-DE") → "1.234,56 €"
// formatCurrency(123456, "JPY", "ja-JP") → "¥1,235"
// formatCurrency(123456, "INR", "en-IN") → "₹1,234.56"
// Client Component using next-intl formatter
export function PriceDisplay({
amountCents,
currency,
}: {
amountCents: number;
currency: string;
}) {
const format = useFormatter();
return (
<span>
{format.number(amountCents / 100, {
style: "currency",
currency: currency.toUpperCase(),
})}
</span>
);
}
// Date formatting
export function DateDisplay({ date }: { date: Date }) {
const format = useFormatter();
return (
<time dateTime={date.toISOString()}>
{format.dateTime(date, {
year: "numeric",
month: "long",
day: "numeric",
// Renders correctly per locale:
// en-US: "September 21, 2026"
// de-DE: "21. September 2026"
// ja-JP: "2026年9月21日"
// ar-SA: "٢١ سبتمبر ٢٠٢٦"
})}
</time>
);
}
// Relative time ("3 days ago", "in 2 hours")
export function RelativeTime({ date }: { date: Date }) {
const format = useFormatter();
return (
<span>
{format.relativeTime(date)}
{/* en: "3 days ago", es: "hace 3 días", ar: "منذ ٣ أيام" */}
</span>
);
}
🚀 SaaS MVP in 8 Weeks — Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment — all handled by one senior team.
- Week 1–2: Architecture design + wireframes
- Week 3–6: Core features built + tested
- Week 7–8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
RTL Layout Support
Right-to-left languages (Arabic, Hebrew, Persian, Urdu) require mirrored layouts. CSS logical properties handle this automatically:
/* ❌ Physical properties — break RTL */
.card {
margin-left: 16px;
padding-right: 12px;
border-left: 2px solid blue;
text-align: left;
float: left;
}
/* ✅ Logical properties — work for both LTR and RTL */
.card {
margin-inline-start: 16px; /* left in LTR, right in RTL */
padding-inline-end: 12px; /* right in LTR, left in RTL */
border-inline-start: 2px solid blue;
text-align: start; /* left in LTR, right in RTL */
}
Setting HTML Direction
// src/app/[locale]/layout.tsx
import { isRTL, type Locale } from "@/i18n";
export default function LocaleLayout({
children,
params: { locale },
}: {
children: React.ReactNode;
params: { locale: Locale };
}) {
const dir = isRTL(locale) ? "rtl" : "ltr";
return (
<html lang={locale} dir={dir}>
<body className={isRTL(locale) ? "font-arabic" : "font-sans"}>
{children}
</body>
</html>
);
}
Tailwind RTL Variants
// tailwind.config.ts — enable RTL plugin
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./src/**/*.{ts,tsx}"],
plugins: [require("tailwindcss-rtl")],
};
// Usage: rtl: prefix mirrors the property
// <div className="ml-4 rtl:mr-4 rtl:ml-0">
// Or with logical property plugin:
// <div className="ms-4"> ← margin-inline-start: 1rem (ltr: left, rtl: right)
// Component with RTL-aware icon
function BackButton() {
const locale = useLocale();
const dir = isRTL(locale as Locale) ? "rtl" : "ltr";
return (
<button className="flex items-center gap-2">
{/* ChevronLeft in LTR, ChevronRight in RTL */}
{dir === "ltr" ? <ChevronLeft /> : <ChevronRight />}
<span>{t("back")}</span>
</button>
);
}
// Or use the CSS transform approach
// .icon { transform: scaleX(1); }
// [dir="rtl"] .icon { transform: scaleX(-1); }
Translation Workflow with Crowdin
Manual translation management doesn't scale past 3 languages. Use a TMS (Translation Management System):
# crowdin.yml — sync config
project_id: "12345"
api_token_env: CROWDIN_PERSONAL_TOKEN
base_path: "."
base_url: "https://api.crowdin.com"
files:
- source: /messages/en.json # English source
translation: /messages/%locale%.json # Output per locale
type: i18next
update_option: update_as_unapproved # Flag changed strings for re-translation
# .github/workflows/i18n-sync.yml
name: Sync Translations
on:
push:
branches: [main]
paths:
- "messages/en.json" # Only sync when source changes
jobs:
upload-source:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Upload source to Crowdin
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: false
crowdin_branch_name: main
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
download-translations:
runs-on: ubuntu-latest
# Run nightly — download completed translations
if: github.event_name == 'schedule'
steps:
- uses: actions/checkout@v4
- name: Download translations
uses: crowdin/github-action@v2
with:
download_translations: true
create_pull_request: true
pull_request_title: "chore: update translations"
pull_request_labels: "translations, automated"
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
💡 The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments — with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity — you own everything
Detecting Missing Translations
// scripts/check-translations.ts
// Run in CI — fail if any locale is missing keys
import { readdirSync, readFileSync } from "fs";
import path from "path";
function flattenKeys(obj: Record<string, unknown>, prefix = ""): string[] {
return Object.entries(obj).flatMap(([key, value]) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
return flattenKeys(value as Record<string, unknown>, fullKey);
}
return [fullKey];
});
}
const messagesDir = path.join(process.cwd(), "messages");
const sourceLocale = "en";
const sourceKeys = flattenKeys(
JSON.parse(readFileSync(path.join(messagesDir, `${sourceLocale}.json`), "utf-8"))
);
const localeFiles = readdirSync(messagesDir)
.filter((f) => f.endsWith(".json") && !f.startsWith(sourceLocale));
let hasErrors = false;
for (const file of localeFiles) {
const locale = file.replace(".json", "");
const messages = JSON.parse(readFileSync(path.join(messagesDir, file), "utf-8"));
const localeKeys = new Set(flattenKeys(messages));
const missing = sourceKeys.filter((k) => !localeKeys.has(k));
const extra = [...localeKeys].filter((k) => !sourceKeys.includes(k));
if (missing.length > 0) {
console.error(`❌ ${locale}: missing ${missing.length} keys:`);
missing.slice(0, 10).forEach((k) => console.error(` - ${k}`));
hasErrors = true;
}
if (extra.length > 0) {
console.warn(`⚠️ ${locale}: ${extra.length} extra keys (stale translations)`);
}
if (missing.length === 0) {
console.log(`✅ ${locale}: complete (${localeKeys.size} keys)`);
}
}
if (hasErrors) process.exit(1);
Cost Reference (2026)
| Component | Tool | Cost |
|---|---|---|
| TMS platform | Crowdin | $0 (open source) to $500/mo |
| TMS platform | Phrase (Memsource) | $250–$1,000/mo |
| Professional translation | Human translators | $0.08–$0.25/word |
| Machine translation | DeepL API | $5.49/1M chars |
| Machine translation (post-edit) | DeepL + human review | $0.03–$0.08/word |
| next-intl library | Open source | $0 |
Typical i18n project cost:
- 5 languages, 10,000 words: $4,000–$12,500 (professional) or $500–$1,500 (MT + review)
- Ongoing maintenance per language per month: $200–$800
See Also
- Next.js App Router Patterns — App Router architecture
- Next.js Performance Optimization — performance with i18n
- Web Accessibility Engineering — a11y + i18n intersection
- SaaS Onboarding UX Engineering — locale-aware onboarding
Working With Viprasol
We've built i18n systems for SaaS products launching across 5–15 markets simultaneously. From next-intl architecture to RTL layout testing to Crowdin pipeline automation, we handle the complete internationalization stack — so your team ships to new markets in days, not months.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours — fast.
Free consultation • No commitment • Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow — automating repetitive tasks, qualifying leads, and responding across every channel your customers use.