Wil je met je SaaS de internationale markt op? Dan is een goede i18n-strategie geen luxe, maar een vereiste. In dit artikel laten we zien hoe je internationalisering (i18n) en lokalisatie (l10n) écht goed aanpakt — van architectuur tot implementatie.
Waarom i18n vanaf het begin?
Een van de duurste fouten die SaaS-founders maken: internationalisering pas overwegen als ze al klanten in het buitenland hebben. Op dat moment zit je hele codebase vol met hardcoded strings, locale-specifieke datum- en valutaformaten, en aannames over taal die overal verweven zitten.
De kosten van later toevoegen:
- 3-5x duurder dan i18n vanaf dag één meenemen
- Honderden bestanden die aangepast moeten worden
- Risico op regressie-bugs in bestaande functionaliteit
- Gefrustreerde ontwikkelaars en gemiste deadlines
De architectuur van een meertalige SaaS
1. Content vs. Interface
Maak een duidelijk onderscheid tussen twee soorten tekst:
- Interface-tekst: knoppen, labels, foutmeldingen, navigatie — beheerd via i18n-frameworks
- Gebruikerscontent: berichten, beschrijvingen, notities — opgeslagen in de database
// Interface-tekst: via i18n framework
const t = useTranslations('dashboard');
return <h1>{t('welcome', { name: user.name })}</h1>;
// Gebruikerscontent: uit de database
const product = await db.product.findFirst({
where: { id: productId },
include: { translations: { where: { locale } } }
});
2. Database-ontwerp voor meertalige content
Er zijn drie gangbare patronen:
Patroon A: Aparte kolommen per taal
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
);
❌ Schaalt slecht. Elke nieuwe taal = schema-migratie.
Patroon B: Vertaaltabel (aanbevolen)
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)
);
✅ Schaalt perfect. Nieuwe taal = alleen data, geen schema-wijziging.
Patroon C: JSON-kolom
CREATE TABLE products (
id TEXT PRIMARY KEY,
name JSONB DEFAULT '{}', -- {"nl": "Factuur", "en": "Invoice"}
description JSONB DEFAULT '{}'
);
⚠️ Compact, maar moeilijker te valideren en indexeren.
3. URL-strategie
Drie opties voor het routeren van talen:
| Strategie | Voorbeeld | Geschikt voor |
|---|---|---|
| Subpad | /nl/dashboard | De meeste SaaS-apps |
| Subdomein | nl.app.com | Grote enterprise apps |
| Cookie/header | Geen URL-verschil | Ingelogde dashboards |
Voor de meeste SaaS-applicaties is het subpad-patroon de beste keuze:
// next.config.js (Next.js App Router)
const nextConfig = {
i18n: {
locales: ['nl', 'en', 'de'],
defaultLocale: 'nl',
},
};
Implementatie met Next.js en next-intl
Stap 1: Vertaalbestanden opzetten
messages/
├── nl.json
├── en.json
└── de.json
// messages/nl.json
{
"dashboard": {
"welcome": "Welkom terug, {name}",
"stats": {
"revenue": "Omzet deze maand",
"users": "{count, plural, =0 {Geen gebruikers} one {# gebruiker} other {# gebruikers}}"
}
},
"billing": {
"nextPayment": "Volgende betaling op {date, date, long}",
"amount": "Bedrag: {amount, number, ::currency/EUR}"
}
}
Stap 2: Middleware voor locale-detectie
// middleware.ts
import createMiddleware from 'next-intl/middleware';
export default createMiddleware({
locales: ['nl', 'en', 'de'],
defaultLocale: 'nl',
localeDetection: true, // Detecteert via Accept-Language header
});
export const config = {
matcher: ['/((?!api|_next|.*\\..*).*)']
};
Stap 3: Vertalingen gebruiken in componenten
// 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>
);
}
Datum, tijd en valuta: de verborgen complexiteit
Dit is waar de meeste i18n-implementaties falen. Elke locale heeft eigen conventies:
// Gebruik altijd de Intl API of een 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"
// Datums
const date = new Intl.DateTimeFormat('nl-NL', {
dateStyle: 'long'
}).format(new Date());
// → "25 maart 2026"
Veelgemaakte fouten:
- Datums opslaan als strings in lokaal formaat (altijd UTC/ISO 8601 gebruiken)
- Valutasymbool hardcoden (€ vs $ vs £)
- Aannemen dat een punt de decimaalscheider is
- Tijdzones negeren bij het tonen van datums
Vertaalworkflow automatiseren
Handmatig vertalen schaalt niet. Zet een geautomatiseerde workflow op:
// 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-integratie
Voeg een check toe aan je 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-ondersteuning (Right-to-Left)
Als je Arabisch, Hebreeuws of Farsi wilt ondersteunen, moet je layout bidirectioneel maken:
// 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>
);
}
/* Gebruik logical properties in plaats van left/right */
.sidebar {
/* ❌ Breekt bij RTL */
margin-left: 1rem;
padding-right: 2rem;
/* ✅ Werkt voor LTR én RTL */
margin-inline-start: 1rem;
padding-inline-end: 2rem;
}
SEO voor meertalige SaaS
Vergeet de technische SEO niet:
<!-- Elke pagina heeft hreflang-tags nodig -->
<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" />
// Dynamische 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: ben je klaar voor internationalisering?
- Geen hardcoded strings in componenten
- Datums en valuta via Intl API of i18n-framework
- Database-schema ondersteunt meertalige content
- URL-strategie gekozen en geïmplementeerd
- Vertaalworkflow geautomatiseerd
- Hreflang-tags op alle pagina's
- Pluralisatie en geslacht correct afgehandeld
- Tijdzones consistent verwerkt (UTC opslag, lokale weergave)
- Formulieren valideren locale-specifieke input
- Taalswitch UI-component aanwezig
Conclusie
Internationalisering is meer dan alleen teksten vertalen. Het raakt je database-ontwerp, je routing, je formattering, je SEO en je deployment-pipeline. Door i18n vanaf het begin mee te nemen in je architectuur, bespaar je jezelf enorm veel werk — en open je de deur naar internationale groei.
Begin klein: start met twee talen, gebruik een bewezen framework zoals next-intl, en bouw je vertaalworkflow stap voor stap uit. De investering betaalt zich dubbel en dwars terug zodra je eerste internationale klant binnenkomt.