Back to Blog

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.

Viprasol Tech Team
September 21, 2026
13 min read

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)

ComponentToolCost
TMS platformCrowdin$0 (open source) to $500/mo
TMS platformPhrase (Memsource)$250–$1,000/mo
Professional translationHuman translators$0.08–$0.25/word
Machine translationDeepL API$5.49/1M chars
Machine translation (post-edit)DeepL + human review$0.03–$0.08/word
next-intl libraryOpen 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


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.

SaaS development services → | Talk to our team →

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

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours — fast.

Free consultation • No commitment • Response within 24 hours

Viprasol · AI Agent Systems

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.