Use case · Multi-locale i18n

A SaaS that reads natively in every language it ships, not three rewrites of the same English copy

Typed string dictionaries per locale, locale routing under `/[lang]` with redirects from the legacy URLs, hreflang tags Google reads correctly, a native review pipeline so the Spanish copy does not sound like English in Spanish. Pricing, date, and number formatting tied to the locale. One repo, one design system, four languages.

The problem

Three rewrites of the same English copy is not a multilingual product

The pattern fails the same way every time. The founder hires a translator, exports the strings to a Google Sheet, gets them back in three languages, drops them into a JSON file, and ships. Six months later the team has 40% of the strings translated (the rest say `[en] sign up`), Spanish customers complain that the copy reads like Google Translate from 2014, Google indexes only the English routes because the hreflang tags are wrong, and the pricing page shows euros to American customers because the currency is hardcoded. We rebuild the i18n surface from the inside out. Typed dictionaries with one source of truth per locale. Routing under `/[lang]` that respects browser language and stays sticky once the user picks. Native review of every translation, never an AI dump. SEO that ranks each locale in its own market. The product reads natively in every language the founder is willing to support.

Our approach

Six steps from one locale to four that read natively

Copy audit before structure. Structure before routing. Routing before SEO. SEO before review. Review before launch. Skipping the audit produces a codebase where 30% of the strings are not in the dictionary; skipping native review produces copy that loses the deal at the first sentence.

  1. 01

    Audit the copy

    We grep every JSX, MDX, email, push notification, and database seed for hardcoded user-facing strings. Output: a CSV with file, line, and the string itself. A real SaaS audit returns 800 to 2,500 strings; half are in components, a third are in marketing pages, the rest are in emails, error messages, and seed data. The audit is the brief; nothing else moves before the founder sees the number.

  2. 02

    Build the dictionary structure

    One typed dictionary per locale, organised by surface (`nav`, `home`, `pricing`, `account`, `emails`). The dictionary is a TypeScript module, not a JSON file, so missing keys fail at compile time. Each key has a one-line comment explaining context (where it appears, what action it precedes, what tone it carries). Translators read the comments; the result is copy that fits the moment, not the literal English.

  3. 03

    Locale routing

    URL structure becomes `/[lang]/...`. The middleware reads the `Accept-Language` header on the first visit, picks the best locale from the supported list, and redirects. A cookie remembers the choice once the user changes it. The legacy URLs (the ones that shipped before i18n) get 301 redirects to the new locale-prefixed URLs. The redirect map is generated once from the route inventory; existing inbound links and email campaigns keep working.

  4. 04

    hreflang and canonical

    Every page emits `<link rel="alternate" hreflang="...">` for each supported locale plus `x-default`. Canonical URLs are set per locale, not collapsed to the English one. The sitemap lists every locale of every page. Google indexes each market correctly; the Spanish landing page ranks in Spain, the Italian one ranks in Italy, neither competes with the English one.

  5. 05

    Native review pipeline

    Translations go through a native speaker review before they land in the repo. We work with the founder's existing translators where possible; we have a roster of three for English, Italian and Spanish where the founder does not. Each translation gets a context note (where it appears, what action it precedes) and a 24-hour SLA. AI-assisted first drafts are allowed; AI-only translations are not. The review tags the strings that need cultural rework, not just literal rewrites.

  6. 06

    Format and currency

    Dates, numbers, currencies, and ordinals come from `Intl.*`. The locale carries the format; nothing is hardcoded. American customers see dollars and MM/DD/YYYY; German customers see euros and DD.MM.YYYY; Japanese customers see yen and a calendar that respects the locale. Currencies that change with the customer's billing country (not the page locale) read from the billing record, not the URL. The two cases are explicit in the API.

What we deliver

Copy audit

A CSV listing every hardcoded user-facing string in the codebase, with file, line, and a tag for `component`, `page`, `email`, `error`, or `seed`. The artefact the rest of the engagement reads. Re-runnable; a lint rule keeps new hardcoded strings from creeping in.

Typed dictionary scaffold

One TypeScript module per locale, organised by surface. Strict type-checked; missing keys are compile errors. Comments on every key explaining context. The English locale is the source of truth; non-English dictionaries inherit the keys.

Translator brief

A document the translator reads before starting. Brand voice (in the founder's words, not a marketing template), tone register per surface (formal for legal, conversational for product, direct for marketing), a glossary of terms that must stay consistent. The doc the founder writes once and the translators read every time.

Locale routing middleware

A Next.js middleware that reads `Accept-Language`, picks the best supported locale, redirects on first visit, and respects the cookie on subsequent visits. Documented. The path `app.x/it` always serves Italian; the path `app.x/` redirects to the user's locale.

Legacy URL redirect map

A redirect file that maps every pre-i18n URL to the equivalent localised URL with a 301. Generated once from the route inventory; covers email campaigns, inbound links, sitemap-indexed pages. Ships in the same PR as the routing change so no link breaks during cutover.

hreflang and canonical setup

A helper that emits `<link rel="alternate" hreflang="...">` for each locale on every page, plus `x-default`, plus canonical. Surfaces a sitemap that lists every locale of every page. Tested against Google Search Console after cutover; we ship the fixes that the console flags in the first two weeks.

Translation review workflow

A Linear-or-similar workflow with one ticket per surface per locale. Translators take tickets, reviewers (native speakers, sometimes the founder) approve or request changes. The workflow lives outside the codebase; the strings land in the repo only after review.

Format helpers

A small library of `formatDate`, `formatCurrency`, `formatNumber`, `formatOrdinal` wrappers that take the locale (and, for currency, the billing country) and return the formatted string. Every place in the codebase that formats user-facing values uses these. Hardcoded format strings get blocked by lint.

Email and notification translations

Every transactional email and push notification body lives in the same dictionary as the in-product copy. The reset-password email reads naturally in Italian because the Italian translator reviewed it; the AI-detected one does not.

SEO per locale

Localised `<title>` and `<meta description>` per page per locale. Open Graph tags localised. Structured data localised where it carries content (FAQ pages, Breadcrumb, Article). The team that searched for the Italian product in Italian finds the Italian page, not the English fallback.

Translation CI guards

A CI step that flags missing keys, untranslated keys (English text in a non-English dictionary), and keys present in non-English dictionaries that no longer exist in English. The dictionaries stay in sync; a developer who adds a new key adds it to every locale (or marks it as `pending_translation` with an issue link).

Launch checklist and post-launch audit

A 20-line checklist the team runs before each new-locale launch (sitemap submitted, hreflang verified, currency tested, redirect map confirmed, OG images regenerated). A 30-day post-launch audit measures search rank per locale and ticket volume from each market; the result drives the next round of translation work.

Five files that compose a multi-locale Next.js product

The five files below compose the i18n surface: the typed dictionary loader, the locale routing middleware, the hreflang and canonical helper, the format helpers, and the translation CI guard.

A multi-locale build is a copy problem before it is a routing problem. The routing, the hreflang, the dictionary scaffold are all solved by week three. The work that remains is making the product read naturally to a native speaker, and that work depends on the source copy being clear, the brief being detailed, and the reviewer being a native speaker who can tell the difference between a translation and a rewrite.

The five files below are the scaffolding the engagement leaves behind. The typed dictionary loader, the locale routing middleware, the hreflang and canonical helper, the format helpers, and the translation CI guard.

1. The typed dictionary loader

The English dictionary is the source of truth. Other locales import the English type and must implement every key; missing keys are compile errors, not runtime bugs that surface in production.

// src/config/i18n/en.ts
export const en = {
  nav: {
    home: 'Home',
    pricing: 'Pricing',
    signIn: 'Sign in',
  },
  pricing: {
    title: 'Pricing that scales with you',
    subtitle: 'No setup fee. Cancel anytime.',
    cta: 'Start free trial',
  },
  emails: {
    welcome: {
      subject: 'Welcome to {productName}',
      body: 'You are in. The next step is …',
    },
  },
} as const

export type Dictionary = typeof en
// src/config/i18n/it.ts
import type { Dictionary } from './en'

export const it: Dictionary = {
  nav: {
    home: 'Home',
    pricing: 'Prezzi',
    signIn: 'Accedi',
  },
  pricing: {
    title: 'Prezzi che crescono con te',
    subtitle: 'Nessun costo di attivazione. Cancelli quando vuoi.',
    cta: 'Prova gratis',
  },
  emails: {
    welcome: {
      subject: 'Benvenuto su {productName}',
      body: 'Sei dentro. Il prossimo passo è …',
    },
  },
}
// src/config/i18n/index.ts
import { en } from './en'
import { it } from './it'
import { es } from './es'

export const locales = ['en', 'it', 'es'] as const
export type Locale = (typeof locales)[number]

const dictionaries: Record<Locale, typeof en> = { en, it, es }

export function getDictionary(locale: Locale): typeof en {
  return dictionaries[locale] ?? en
}

2. The locale routing middleware

The middleware reads the URL, the Accept-Language header, and a cookie. First visits get redirected to the best locale; subsequent visits respect the cookie. The cookie is set when the user changes locale through a language picker.

// middleware.ts
import { NextResponse, type NextRequest } from 'next/server'
import { locales, type Locale } from '@/config/i18n'

const DEFAULT_LOCALE: Locale = 'en'

function pickLocale(req: NextRequest): Locale {
  const cookie = req.cookies.get('NEXT_LOCALE')?.value
  if (cookie && locales.includes(cookie as Locale)) return cookie as Locale

  const accept = req.headers.get('accept-language') ?? ''
  for (const part of accept.split(',')) {
    const tag = part.split(';')[0].trim().toLowerCase().split('-')[0]
    if (locales.includes(tag as Locale)) return tag as Locale
  }
  return DEFAULT_LOCALE
}

export function middleware(req: NextRequest): NextResponse {
  const { pathname } = req.nextUrl

  if (locales.some((l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`))) {
    return NextResponse.next()
  }

  const locale = pickLocale(req)
  const url = req.nextUrl.clone()
  url.pathname = `/${locale}${pathname === '/' ? '' : pathname}`
  return NextResponse.redirect(url)
}

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

3. The hreflang and canonical helper

The helper produces the <link> tags every page emits. Canonical points to the page in the current locale; hreflang lists every supported locale; x-default points to the English version.

// src/lib/seo/alternates.ts
import { locales, type Locale } from '@/config/i18n'

const BASE = process.env.PUBLIC_APP_URL ?? 'https://adamarant.com'

export function hreflang(pathByLocale: Record<Locale, string>, current: Locale) {
  const alternates: Record<string, string> = {}
  for (const locale of locales) {
    alternates[locale] = `${BASE}${pathByLocale[locale]}`
  }
  alternates['x-default'] = `${BASE}${pathByLocale.en}`

  return {
    canonical: alternates[current],
    languages: alternates,
  }
}
// app/[lang]/pricing/page.tsx
import type { Metadata } from 'next'
import { hreflang } from '@/lib/seo/alternates'

export async function generateMetadata({ params }: { params: Promise<{ lang: 'en' | 'it' | 'es' }> }): Promise<Metadata> {
  const { lang } = await params
  return {
    alternates: hreflang(
      {
        en: '/en/pricing',
        it: '/it/prezzi',
        es: '/es/precios',
      },
      lang,
    ),
  }
}

4. The format helpers

Format helpers wrap Intl.*. Hardcoded format strings are blocked by lint; every formatted value goes through the helper, which takes a locale and (for currency) the billing country.

// src/lib/format.ts
import type { Locale } from '@/config/i18n'

const LOCALE_TAG: Record<Locale, string> = {
  en: 'en-US',
  it: 'it-IT',
  es: 'es-ES',
}

export function formatDate(date: Date, locale: Locale): string {
  return new Intl.DateTimeFormat(LOCALE_TAG[locale], { dateStyle: 'long' }).format(date)
}

export function formatNumber(value: number, locale: Locale): string {
  return new Intl.NumberFormat(LOCALE_TAG[locale]).format(value)
}

export function formatCurrency(amountCents: number, currency: string, locale: Locale): string {
  return new Intl.NumberFormat(LOCALE_TAG[locale], {
    style: 'currency',
    currency,
    minimumFractionDigits: 2,
  }).format(amountCents / 100)
}

5. The translation CI guard

A CI step diffs the dictionaries and fails the build when a non-English dictionary is missing a key, has English text where it should be translated, or has a key that no longer exists in English. The dictionaries stay in sync, and the team is told before the deploy, not after.

// scripts/i18n/check.ts
import { en } from '../../src/config/i18n/en'
import { it } from '../../src/config/i18n/it'
import { es } from '../../src/config/i18n/es'

type Dictionary = typeof en

function diffKeys(a: Dictionary, b: Dictionary, path = ''): string[] {
  const issues: string[] = []
  for (const key of Object.keys(a) as Array<keyof Dictionary>) {
    const here = path ? `${path}.${String(key)}` : String(key)
    if (!(key in b)) {
      issues.push(`MISSING in target: ${here}`)
      continue
    }
    const aValue = a[key] as unknown
    const bValue = (b as Record<string, unknown>)[String(key)]
    if (typeof aValue === 'object' && aValue && !Array.isArray(aValue)) {
      issues.push(...diffKeys(aValue as Dictionary, bValue as Dictionary, here))
    } else if (typeof aValue === 'string' && typeof bValue === 'string') {
      if (aValue === bValue && a !== b) {
        issues.push(`UNTRANSLATED in target (matches English): ${here}`)
      }
    }
  }
  for (const key of Object.keys(b) as Array<keyof Dictionary>) {
    if (!(key in a)) {
      const here = path ? `${path}.${String(key)}` : String(key)
      issues.push(`STALE in target (no longer in English): ${here}`)
    }
  }
  return issues
}

const issues = [...diffKeys(en, it as Dictionary, 'it'), ...diffKeys(en, es as Dictionary, 'es')]

if (issues.length > 0) {
  console.error('i18n issues:\n' + issues.join('\n'))
  process.exit(1)
}
console.log('i18n: all dictionaries in sync')

6. What this composes

The typed dictionaries keep every string under one roof and fail the build when one goes missing. The middleware sends every visitor to the right locale and respects their choice. The hreflang helper tells Google each market exists and which page belongs to which. The format helpers stop the dollar sign from showing up in Spain. The CI guard stops the next developer from shipping a half-translated page.

The product reads natively to a Spanish customer, to an Italian customer, to a French customer who joins next. The Italian landing page ranks in Italy because Google sees a real Italian page, not a translation of an English page. The pricing page shows euros in Madrid and dollars in San Francisco without anyone editing a switch statement. The team that used to think launching in a new market was a quarter of work now thinks of it as a translation sprint and a sitemap submission.

Related stacks

Frequently asked questions

Can we add a locale after launch without rebuilding the i18n surface?

Yes, by design. The dictionary scaffold and routing handle N locales; adding a fifth is one PR plus the translation work. The build cost is mostly the audit and the structure; once those exist, each additional locale takes one to three weeks of translation review, not a rebuild.

Do we need a translation management system (Lokalise, Crowdin, Phrase)?

Often no, especially below 1,500 strings and 4 locales. The TypeScript dictionaries plus a Linear workflow handle that scale well; the TMS adds cost and indirection that small teams do not need. Above that scale, or when translators need a glossary and translation memory, we wire up Lokalise or similar and keep the typed dictionaries as the source of truth.

What about AI-assisted translation?

AI-assisted first drafts are fine and we use them. AI-only translations are not. The native reviewer catches the calque, the cultural mismatch, the brand voice that drifts toward the model's default. The pipeline forces a native eye on every string; the AI speeds the first pass.

How do you handle pricing in different currencies?

Two patterns, both supported. Pattern A: pricing is locale-dependent (the EUR price shows on the Spanish page, the USD price on the US page). Pattern B: pricing follows the customer's billing country (the EUR price shows to a Spanish customer regardless of which locale they browse in). Most SaaS does B; the API distinguishes locale and billing country explicitly.

What if our app is a hybrid (some pages translated, some not)?

The dictionary supports partial coverage. Pages that have a translated key serve the translation; pages that do not fall back to English with a console warning. The audit surfaces the coverage gap so the founder decides what to translate next. We do not block the launch because three error messages are still in English.

How long does it take?

Six to ten weeks for the first new locale after kickoff, depending on string count. The copy audit takes one week. The dictionary scaffold and routing take one week. The translation review across 800 to 2,500 strings takes three to six weeks (parallelisable across translators). hreflang, currency, and email translations ship in the last two weeks. Adding the next locale after that takes two to three weeks each.

What about right-to-left languages (Arabic, Hebrew)?

In scope when the founder asks for them; we audit the CSS for logical properties (the design system already uses `padding-inline` etc.) and ship an RTL variant. RTL doubles the QA effort for the affected pages; we flag the work in the scoping call so the timeline matches the scope.

Do you replace our copywriter or marketing team?

No. The pipeline makes their work load-bearing in every language. The English copywriter is the source; the native translators are the second sources; the founder reviews the brand voice in each language. The engagement leaves the team able to ship a new campaign in four languages in one week, not four months.

Scope your i18n build

A scoping call, a copy audit in week one, a fixed scope and a number we hold. Six to ten weeks from kickoff to a product that reads natively in the locales you ship, with the SEO and the formatting that goes with it.