SaaS Localization: i18n, Currency Formatting, RTL Support, and Translation Workflows
Localize your SaaS product for global markets — i18n architecture with next-intl, currency and date formatting, RTL layout support for Arabic and Hebrew, transl
SaaS Localization: i18n, Currency Formatting, RTL Support, and Translation Workflows
Localization is not just translation. It's adapting your product to feel native in each market — correct date formats (DD/MM/YYYY vs MM/DD/YYYY), currencies with proper decimal separators, right-to-left text layouts for Arabic and Hebrew users, and pluralization rules that vary dramatically across languages ("1 item" vs "2 items" is simple; Polish and Russian have four plural forms).
Getting this right opens your product to global markets without requiring separate codebases.
Architecture: next-intl for Next.js
npm install next-intl
// middleware.ts — locale detection and routing
import createMiddleware from 'next-intl/middleware';
export default createMiddleware({
locales: ['en', 'de', 'fr', 'ar', 'ja', 'pt-BR'],
defaultLocale: 'en',
localeDetection: true, // Auto-detect from Accept-Language header
// URL strategy: /en/dashboard, /de/dashboard, /ar/dashboard
localePrefix: 'as-needed', // Omit prefix for default locale
});
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
};
// next.config.ts
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
export default withNextIntl({});
// i18n/request.ts — load messages per locale
import { getRequestConfig } from 'next-intl/server';
export default getRequestConfig(async ({ locale }) => ({
messages: (await import(`../messages/${locale}.json`)).default,
timeZone: 'UTC', // Override per-user if needed
}));
Message Files
// messages/en.json
{
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"loading": "Loading..."
},
"billing": {
"title": "Billing",
"nextPayment": "Your next payment of {amount} is due on {date}.",
"invoiceCount": "{count, plural, =0 {No invoices} =1 {1 invoice} other {{count} invoices}}",
"planUpgraded": "{name} upgraded to {plan} plan"
},
"errors": {
"notFound": "Page not found",
"unauthorized": "You don't have permission to do that"
}
}
// messages/de.json
{
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"loading": "Laden..."
},
"billing": {
"title": "Abrechnung",
"nextPayment": "Ihre nächste Zahlung von {amount} ist fällig am {date}.",
"invoiceCount": "{count, plural, =0 {Keine Rechnungen} =1 {1 Rechnung} other {{count} Rechnungen}}",
"planUpgraded": "{name} auf {plan}-Plan aktualisiert"
}
}
// Usage in components
import { useTranslations, useFormatter } from 'next-intl';
export function BillingPage({ nextPaymentAmount, nextPaymentDate, invoiceCount }: Props) {
const t = useTranslations('billing');
const format = useFormatter();
return (
<div>
<h1>{t('title')}</h1>
{/* ICU message format with named parameters */}
<p>
{t('nextPayment', {
amount: format.number(nextPaymentAmount / 100, {
style: 'currency',
currency: 'USD',
}),
date: format.dateTime(nextPaymentDate, {
dateStyle: 'long',
}),
})}
</p>
{/* Pluralization */}
<p>{t('invoiceCount', { count: invoiceCount })}</p>
</div>
);
}
🌐 Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript — production-grade stack
- Fixed-price contracts — no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Currency and Number Formatting
Never hardcode currency symbols or decimal separators. Use the Intl API:
// lib/formatting.ts
export function formatCurrency(
amountCents: number,
currency: string,
locale: string
): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(amountCents / 100);
}
// Examples:
formatCurrency(99900, 'USD', 'en-US') // "$999.00"
formatCurrency(99900, 'EUR', 'de-DE') // "999,00 €"
formatCurrency(99900, 'JPY', 'ja-JP') // "¥99,900" (no cents — JPY is integer)
formatCurrency(99900, 'INR', 'en-IN') // "₹999.00"
formatCurrency(99900, 'AED', 'ar-AE') // "٩٩٩٫٠٠ د.إ." (Arabic numerals)
// Date formatting
export function formatDate(
date: Date,
locale: string,
format: 'short' | 'long' | 'relative' = 'short'
): string {
if (format === 'relative') {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
const diffMs = date.getTime() - Date.now();
const diffDays = Math.round(diffMs / 86_400_000);
return rtf.format(diffDays, 'day');
// en: "yesterday", "2 days ago"
// de: "gestern", "vor 2 Tagen"
// ar: "أمس", "قبل يومين"
}
return new Intl.DateTimeFormat(locale, {
dateStyle: format,
timeStyle: format === 'long' ? 'short' : undefined,
}).format(date);
}
// Locale-aware sorting
export function sortByName(items: { name: string }[], locale: string) {
const collator = new Intl.Collator(locale, { sensitivity: 'base' });
return [...items].sort((a, b) => collator.compare(a.name, b.name));
// Handles: ñ in Spanish, ä/ö/ü in German, Arabic/Hebrew character order
}
RTL (Right-to-Left) Support
Arabic, Hebrew, Persian, and Urdu are RTL languages. A well-implemented RTL layout requires:
dir="rtl"on<html>: Browser flips text direction and many layout behaviors- Logical CSS properties: Use
margin-inline-startinstead ofmargin-left - Flip icons: Arrows, chevrons, and directional icons should mirror
- Test with real content: Arabic text expands significantly compared to English
// app/[locale]/layout.tsx
import { getLocale } from 'next-intl/server';
const RTL_LOCALES = ['ar', 'he', 'fa', 'ur'];
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const locale = await getLocale();
const dir = RTL_LOCALES.includes(locale) ? 'rtl' : 'ltr';
return (
<html lang={locale} dir={dir}>
<body>{children}</body>
</html>
);
}
CSS with logical properties (RTL-safe):
/* ❌ Physical properties — break in RTL */
.sidebar {
margin-left: 240px;
border-right: 1px solid #e5e7eb;
padding-left: 16px;
text-align: left;
}
/* ✅ Logical properties — work in both LTR and RTL */
.sidebar {
margin-inline-start: 240px; /* margin-left in LTR, margin-right in RTL */
border-inline-end: 1px solid #e5e7eb;
padding-inline-start: 16px;
text-align: start;
}
Tailwind CSS v4 RTL support:
// Tailwind v4 supports logical properties natively
<div className="ms-4 ps-4 border-e text-start">
{/* ms = margin-inline-start, ps = padding-inline-start */}
{/* border-e = border-inline-end, text-start = text-align: start */}
</div>
// For icons that need to flip:
<ChevronRightIcon className={`${isRTL ? 'rotate-180' : ''} h-5 w-5`} />
// Or use CSS: [dir="rtl"] .flip-rtl { transform: scaleX(-1); }
🚀 Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4–8 weeks, full platforms in 3–5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Pluralization Edge Cases
English has two plural forms (1 / other). Many languages have more:
// Russian: 4 plural forms
// 1 файл, 2 файла, 5 файлов, 11 файлов
// Polish: similar complexity
// Arabic: 6 plural forms (!)
// next-intl uses ICU message format which handles all of these correctly
// messages/ru.json
{
"fileCount": "{count, plural, =0 {Нет файлов} one {{count} файл} few {{count} файла} many {{count} файлов} other {{count} файла}}"
}
// The ICU library knows the rules for each locale — you don't need to implement them
Translation Workflow
For efficient translation management:
1. Developer adds English strings to messages/en.json
2. Strings synced to translation platform (Phrase, Lokalise, Crowdin)
3. Translators work in platform (with context screenshots)
4. Translations exported back to messages/{locale}.json
5. PR created with new translations
6. CI checks for missing keys in all locales
CI check for missing translation keys:
// scripts/checkTranslations.ts
import { readdirSync, readFileSync } from 'fs';
function getAllKeys(obj: Record<string, unknown>, prefix = ''): string[] {
return Object.entries(obj).flatMap(([key, value]) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
return typeof value === 'object' && value !== null
? getAllKeys(value as Record<string, unknown>, fullKey)
: [fullKey];
});
}
async function checkTranslations() {
const messagesDir = './messages';
const locales = readdirSync(messagesDir).map(f => f.replace('.json', ''));
const baseLocale = 'en';
const baseMessages = JSON.parse(readFileSync(`${messagesDir}/${baseLocale}.json`, 'utf8'));
const baseKeys = new Set(getAllKeys(baseMessages));
let hasErrors = false;
for (const locale of locales) {
if (locale === baseLocale) continue;
const messages = JSON.parse(readFileSync(`${messagesDir}/${locale}.json`, 'utf8'));
const localeKeys = new Set(getAllKeys(messages));
const missing = [...baseKeys].filter(k => !localeKeys.has(k));
const extra = [...localeKeys].filter(k => !baseKeys.has(k));
if (missing.length > 0) {
console.error(`❌ ${locale}: Missing ${missing.length} keys:\n ${missing.join('\n ')}`);
hasErrors = true;
}
if (extra.length > 0) {
console.warn(`⚠️ ${locale}: ${extra.length} unused keys`);
}
}
if (hasErrors) process.exit(1);
console.log('✅ All translation files are complete');
}
checkTranslations();
Locale-Aware Database Queries
Store user locale preference; apply to all formatting:
-- Store user locale in DB
ALTER TABLE users ADD COLUMN locale VARCHAR(10) DEFAULT 'en';
ALTER TABLE users ADD COLUMN timezone VARCHAR(50) DEFAULT 'UTC';
ALTER TABLE users ADD COLUMN currency VARCHAR(3) DEFAULT 'USD';
-- PostgreSQL locale-aware string sorting
SELECT name FROM products
ORDER BY name COLLATE "de-DE-x-icu" -- German alphabetical order
LIMIT 20;
Working With Viprasol
We localize SaaS products for global markets — next-intl architecture, translation pipeline setup, RTL layout implementation, currency and date formatting, and locale-aware backend APIs. Proper localization opens markets that competitors ignore.
→ Talk to our team about localizing your product.
See Also
- Next.js Performance Optimization — performance considerations for i18n
- Web Performance Optimization — ensuring localized pages stay fast
- SaaS Pricing Page Design — currency and pricing per region
- Accessibility WCAG — RTL accessibility requirements
- Web Development Services — global SaaS product development
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.
Need a Modern Web Application?
From landing pages to complex SaaS platforms — we build it all with Next.js and React.
Free consultation • No commitment • Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.