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:
| Strategy | Example | Best for |
|---|---|---|
| Subpath | /nl/dashboard | Most SaaS apps |
| Subdomain | nl.app.com | Large enterprise apps |
| Cookie/header | No URL difference | Logged-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.