Terug naar blog
i18nlokalisatienext.jsinternationaliseringsaas-architectuurseo

Internationalisering (i18n) in je SaaS: van hardcoded strings tot wereldwijde schaalbaarheid

Door SaaS Masters25 maart 20267 min leestijd

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:

StrategieVoorbeeldGeschikt voor
Subpad/nl/dashboardDe meeste SaaS-apps
Subdomeinnl.app.comGrote enterprise apps
Cookie/headerGeen URL-verschilIngelogde 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.