Back to blog
i18nlocalizationnext.jsinternationalizationsaas-architectureseo

Internationalization (i18n) in Your SaaS: From Hardcoded Strings to Global Scalability

By SaaS Masters25 maart 20267 min read

Want to take your SaaS international? A solid i18n strategy isn't a luxury — it's a requirement. In this article, we'll show you how to properly implement internationalization (i18n) and localization (l10n) — from architecture to implementation.

Why i18n From Day One?

One of the most expensive mistakes SaaS founders make: only considering internationalization when they already have customers abroad. By then, your entire codebase is riddled with hardcoded strings, locale-specific date and currency formats, and language assumptions woven throughout.

The cost of adding it later:

  • 3-5x more expensive than including i18n from day one
  • Hundreds of files that need modification
  • Risk of regression bugs in existing functionality
  • Frustrated developers and missed deadlines

The Architecture of a Multilingual SaaS

1. Content vs. Interface

Make a clear distinction between two types of text:

  • Interface text: buttons, labels, error messages, navigation — managed via i18n frameworks
  • User content: messages, descriptions, notes — stored in the database
// Interface text: via i18n framework
const t = useTranslations('dashboard');
return <h1>{t('welcome', { name: user.name })}</h1>;

// User content: from the database
const product = await db.product.findFirst({
  where: { id: productId },
  include: { translations: { where: { locale } } }
});

2. Database Design for Multilingual Content

There are three common patterns:

Pattern A: Separate Columns per Language

CREATE TABLE products (
  id TEXT PRIMARY KEY,
  name_nl TEXT,
  name_en TEXT,
  name_de TEXT,
  description_nl TEXT,
  description_en TEXT,
  description_de TEXT
);

❌ Scales poorly. Every new language = schema migration.

Pattern B: Translation Table (recommended)

CREATE TABLE products (
  id TEXT PRIMARY KEY,
  price DECIMAL,
  created_at TIMESTAMP DEFAULT now()
);

CREATE TABLE product_translations (
  id TEXT PRIMARY KEY,
  product_id TEXT REFERENCES products(id) ON DELETE CASCADE,
  locale TEXT NOT NULL,
  name TEXT NOT NULL,
  description TEXT,
  UNIQUE(product_id, locale)
);

✅ Scales perfectly. New language = just data, no schema changes.

Pattern C: JSON Column

CREATE TABLE products (
  id TEXT PRIMARY KEY,
  name JSONB DEFAULT '{}',  -- {"nl": "Factuur", "en": "Invoice"}
  description JSONB DEFAULT '{}'
);

⚠️ Compact, but harder to validate and index.

3. URL Strategy

Three options for routing languages:

StrategyExampleBest for
Subpath/nl/dashboardMost SaaS apps
Subdomainnl.app.comLarge enterprise apps
Cookie/headerNo URL differenceLogged-in dashboards

For most SaaS applications, the subpath pattern is the best choice:

// next.config.js (Next.js App Router)
const nextConfig = {
  i18n: {
    locales: ['nl', 'en', 'de'],
    defaultLocale: 'nl',
  },
};

Implementation with Next.js and next-intl

Step 1: Set Up Translation Files

messages/
├── nl.json
├── en.json
└── de.json
// messages/en.json
{
  "dashboard": {
    "welcome": "Welcome back, {name}",
    "stats": {
      "revenue": "Revenue this month",
      "users": "{count, plural, =0 {No users} one {# user} other {# users}}"
    }
  },
  "billing": {
    "nextPayment": "Next payment on {date, date, long}",
    "amount": "Amount: {amount, number, ::currency/EUR}"
  }
}

Step 2: Middleware for Locale Detection

// middleware.ts
import createMiddleware from 'next-intl/middleware';

export default createMiddleware({
  locales: ['nl', 'en', 'de'],
  defaultLocale: 'nl',
  localeDetection: true, // Detects via Accept-Language header
});

export const config = {
  matcher: ['/((?!api|_next|.*\\..*).*)']
};

Step 3: Using Translations in Components

// app/[locale]/dashboard/page.tsx
import { useTranslations } from 'next-intl';
import { useFormatter } from 'next-intl';

export default function DashboardPage() {
  const t = useTranslations('dashboard');
  const format = useFormatter();

  return (
    <div>
      <h1>{t('welcome', { name: 'Arnold' })}</h1>
      <p>{t('stats.users', { count: 1423 })}</p>
      <p>{format.dateTime(new Date(), { dateStyle: 'long' })}</p>
      <p>{format.number(9999.99, { style: 'currency', currency: 'EUR' })}</p>
    </div>
  );
}

Dates, Times, and Currencies: The Hidden Complexity

This is where most i18n implementations fail. Every locale has its own conventions:

// Always use the Intl API or an i18n framework
const price = new Intl.NumberFormat('nl-NL', {
  style: 'currency',
  currency: 'EUR'
}).format(1234.56);
// → "€ 1.234,56"

const priceUS = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD'
}).format(1234.56);
// → "$1,234.56"

// Dates
const date = new Intl.DateTimeFormat('en-US', {
  dateStyle: 'long'
}).format(new Date());
// → "March 25, 2026"

Common mistakes:

  • Storing dates as strings in local format (always use UTC/ISO 8601)
  • Hardcoding currency symbols (€ vs $ vs £)
  • Assuming a period is the decimal separator
  • Ignoring timezones when displaying dates

Automating the Translation Workflow

Manual translation doesn't scale. Set up an automated workflow:

// scripts/sync-translations.ts
import { readFileSync, writeFileSync } from 'fs';
import OpenAI from 'openai';

const openai = new OpenAI();

async function translateMissing(sourceLang: string, targetLang: string) {
  const source = JSON.parse(readFileSync(`messages/${sourceLang}.json`, 'utf-8'));
  const target = JSON.parse(readFileSync(`messages/${targetLang}.json`, 'utf-8'));

  const missing = findMissingKeys(source, target);

  if (missing.length === 0) {
    console.log(`✅ ${targetLang} is up to date`);
    return;
  }

  console.log(`🔄 Translating ${missing.length} keys to ${targetLang}...`);

  const response = await openai.chat.completions.create({
    model: 'gpt-4o',
    messages: [{
      role: 'system',
      content: `Translate the following JSON values from ${sourceLang} to ${targetLang}. 
                Keep {variables} and ICU syntax intact. Return valid JSON.`
    }, {
      role: 'user',
      content: JSON.stringify(missing)
    }],
  });

  const translated = JSON.parse(response.choices[0].message.content);
  const merged = deepMerge(target, translated);
  writeFileSync(`messages/${targetLang}.json`, JSON.stringify(merged, null, 2));
  console.log(`✅ ${targetLang} updated with ${missing.length} new translations`);
}

CI/CD Integration

Add a check to your pipeline:

# .github/workflows/i18n-check.yml
name: i18n Completeness Check
on: [pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npx i18n-check --source nl --targets en,de --fail-on-missing

RTL Support (Right-to-Left)

If you want to support Arabic, Hebrew, or Farsi, you need to make your layout bidirectional:

// app/[locale]/layout.tsx
import { getLocale } from 'next-intl/server';

const rtlLocales = ['ar', 'he', 'fa'];

export default async function RootLayout({ children }) {
  const locale = await getLocale();
  const dir = rtlLocales.includes(locale) ? 'rtl' : 'ltr';

  return (
    <html lang={locale} dir={dir}>
      <body>{children}</body>
    </html>
  );
}
/* Use logical properties instead of left/right */
.sidebar {
  /* ❌ Breaks with RTL */
  margin-left: 1rem;
  padding-right: 2rem;

  /* ✅ Works for both LTR and RTL */
  margin-inline-start: 1rem;
  padding-inline-end: 2rem;
}

SEO for Multilingual SaaS

Don't forget the technical SEO:

<!-- Every page needs hreflang tags -->
<link rel="alternate" hreflang="nl" href="https://app.nl/nl/features" />
<link rel="alternate" hreflang="en" href="https://app.nl/en/features" />
<link rel="alternate" hreflang="de" href="https://app.nl/de/features" />
<link rel="alternate" hreflang="x-default" href="https://app.nl/features" />
// Dynamic hreflang in Next.js
export async function generateMetadata({ params: { locale } }) {
  const t = await getTranslations({ locale, namespace: 'meta' });
  return {
    title: t('features.title'),
    description: t('features.description'),
    alternates: {
      languages: {
        nl: '/nl/features',
        en: '/en/features',
        de: '/de/features',
      },
    },
  };
}

Checklist: Are You Ready for Internationalization?

  • No hardcoded strings in components
  • Dates and currencies via Intl API or i18n framework
  • Database schema supports multilingual content
  • URL strategy chosen and implemented
  • Translation workflow automated
  • Hreflang tags on all pages
  • Pluralization and gender handled correctly
  • Timezones consistently processed (UTC storage, local display)
  • Forms validate locale-specific input
  • Language switcher UI component present

Conclusion

Internationalization is more than just translating text. It touches your database design, routing, formatting, SEO, and deployment pipeline. By incorporating i18n into your architecture from the start, you save yourself an enormous amount of work — and open the door to international growth.

Start small: begin with two languages, use a proven framework like next-intl, and build out your translation workflow step by step. The investment pays for itself many times over when your first international customer signs up.