Back to Blog

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

Viprasol Tech Team
May 21, 2026
12 min read

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:

  1. dir="rtl" on <html>: Browser flips text direction and many layout behaviors
  2. Logical CSS properties: Use margin-inline-start instead of margin-left
  3. Flip icons: Arrows, chevrons, and directional icons should mirror
  4. 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

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

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

Viprasol · Web Development

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.