Code Agency
9 min read

Multilingual without the retrofit: next-intl or Paraglide, and a self-hosted Tolgee behind them

Language in the URL, slugs in a translation map, detection demoted to a suggestion — plus the tooling question: when we reach for next-intl vs Paraglide JS, and why Tolgee runs the translations from our own clusters.

Somewhere in almost every custom web application intake, the sentence appears: "and it needs to work in multiple languages" — usually delivered as an afterthought, the way you'd mention the logo should go top-left. It is not an afterthought. A shop selling into three countries, a SaaS with customers across Europe, a booking platform serving tourists in eight languages: multilingualism bolted onto a finished product in week six is a rewrite wearing a trench coat. Designed in on day one, it's mostly a routing decision, two libraries, and some discipline.

We've inherited enough half-translated sites — the WPML-wrestling WordPress kind and the hand-rolled ?lang=fr kind — to have strong opinions about what actually holds up. This is the pattern we ship, and the stack behind it.

The language lives in the URL — nowhere else

The single load-bearing rule: every page has exactly one language, and every language has exactly one URL. /en/services, /nl/diensten, /de/leistungen. Not a cookie that swaps strings on the same URL, not a ?lang= parameter, not client-side detection that rewrites the page after load.

The reason is not aesthetics — it's that the biggest visitor to your site can't press a language button. Googlebot crawls without cookies and mostly from US IPs. If language is session state, the crawler sees exactly one language, and every other market you translated for never finds you. A URL per language turns every translation into a first-class, indexable, linkable page.

Two corollaries. Subpaths beat separate domains for almost everyone: a domain per country splits your link authority, multiplies certificate and DNS bookkeeping, and buys you nothing unless the brands genuinely differ per region. And the locale is the only state that belongs in the path — transient UI state like filters, tabs and pagination belongs in the query string, typed and validated (we use nuqs for exactly that), so a filtered German product list survives being pasted into an e-mail.

Translate the slugs, not just the page

A URL is user interface and it's a search keyword. A French-speaking customer searching for an online shop builder should not land on /fr/diensten/webshops — half-translated URLs read exactly as sloppy as half-translated pages, and they throw away the keyword the URL could have carried.

On Next.js builds we do this with next-intl, because it makes localized pathnames a single declarative map instead of a rats' nest of redirects:

i18n/routing.ts
import { defineRouting } from "next-intl/routing"
 
export const routing = defineRouting({
  locales: ["en", "nl", "fr", "de"],
  defaultLocale: "en",
  localePrefix: "always",
  pathnames: {
    "/": "/",
    "/services/webshops": {
      en: "/services/webshops",
      nl: "/diensten/webshops",
      fr: "/services/boutiques-en-ligne",
      de: "/leistungen/webshops",
    },
    "/about": {
      en: "/about",
      nl: "/over-ons",
      fr: "/a-propos",
      de: "/ueber-uns",
    },
  },
})

Internal links go through the library's locale-aware Link, typed against this map. Rename a French slug and it's one line in one file — every link, sitemap entry and hreflang tag follows. The hand-rolled alternative is grep-and-pray.

Detect once, suggest — never force

The most common request we talk clients out of: "can the site detect where the visitor is and switch language automatically?"

Geo-IP guessing is wrong more often than the demo suggests. An IP address tells you where a request comes from, not what language the person reads. It collapses completely in multilingual countries — an IP in Brussels, Zurich or Montreal says nothing about the visitor's language — and it's wrong for every traveller, every expat, every office VPN that exits in another country.

And forced redirects don't just guess wrong — they break things that were working:

  • A crawler requesting /fr/services and getting 302'd to /en/services because the datacenter IP "looks English" will simply never index French.
  • Someone shares a link with a colleague abroad; the site yanks the colleague to a language they didn't ask for.
  • The back button stops working, because the redirect fires again.

So: detection is a suggestion, not a routing decision. Deep links always render exactly the locale in the URL. On a first visit, Accept-Language — an actual statement about language, unlike an IP — may power a small dismissible banner: "This page is also available in English." An explicit choice is stored in a cookie and respected from then on, and the only place that cookie influences routing is the bare / root, after the user has actually picked something.

hreflang is the contract with search engines

Four URLs per page means telling search engines they're the same page. Every locale version declares all of its siblings, itself, and an x-default. With the App Router this is the alternates field in generateMetadata, derived from the same pathname map so it can't drift:

app/[locale]/services/[slug]/page.tsx
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { locale, slug } = await params
  const paths = servicePaths(slug) // { en: "/services/…", nl: "/diensten/…", … }
 
  return {
    alternates: {
      canonical: `${BASE_URL}/${locale}${paths[locale]}`,
      languages: {
        en: `${BASE_URL}/en${paths.en}`,
        nl: `${BASE_URL}/nl${paths.nl}`,
        fr: `${BASE_URL}/fr${paths.fr}`,
        de: `${BASE_URL}/de${paths.de}`,
        "x-default": `${BASE_URL}/en${paths.en}`,
      },
    },
  }
}

Get this wrong and Google serves the English page to French searchers, who experience it as "the site is broken" and bounce. Get it right and each language ranks on its own keywords — which is the entire commercial point of translating the site.

next-intl or Paraglide JS? We use both — for different jobs

The library question comes up on every project, so here's our honest split.

next-intl is the default on Next.js builds. Half the i18n problem in Next.js is routing and metadata — localized pathnames, the middleware, locale-aware Links, alternates — and next-intl is the library that treats those as first-class citizens of the App Router. Add ICU message formatting and server-side Intl helpers and there's very little left to hand-roll. It's the de-facto standard for App Router i18n for a reason, and nothing about it is legacy.

Paraglide JS is what we reach for when the constraints change. Paraglide is a compiler, not a runtime: messages compile to typed, tree-shakeable ESM functions, so each page ships only the strings it actually renders — no dictionary payload, no runtime lookup, autocomplete and compile-time errors on every message call. And because it's framework-agnostic — SvelteKit, TanStack Start, Astro, React Router, plain Vite, with a Next.js setup and AsyncLocalStorage-based SSR middleware — the same compiled messages package can serve a monorepo where not everything is Next. When a marketing site has a hard performance budget, or a shared package needs translations without dragging a framework runtime along, the compiler approach wins.

What they have in common matters more than the differences: both keep translations as plain JSON files in git. Messages are code — reviewed in PRs, deployed atomically with the release that uses them. Which is exactly what makes the next layer pluggable.

Tolgee: the translation workflow, self-hosted

The library is the smaller half of the problem. What actually rots on multilingual projects is the workflow: translations living in a spreadsheet, e-mailed to an agency, pasted back by a developer, drifting out of sync with the keys in the codebase by sprint three.

We put Tolgee behind our i18n setups: an open-source translation management system that we self-host on our own clusters, next to the applications it serves. Self-hosting isn't ideology here — translation memory is client data, per-seat SaaS pricing punishes exactly the "invite the whole team plus the external translator" usage a TMS exists for, and open source means the workflow survives any vendor's pricing pivot.

The features that changed how projects run day-to-day:

  • In-context editing. Reviewers fix copy on the live site — Tolgee's dev integration lets them click any string and edit it in place, with screenshots attached to keys so translators see where a string lives. No more "row 214 in the spreadsheet".
  • Machine translation as a first draft, never as the product. New keys get AI-suggested translations immediately, so nothing ships with raw keys showing; a human approves before anything reaches production.
  • Translations flow through CI, not through people. The Tolgee CLI pulls approved translations into messages/{locale}.json in a pull request — the same files next-intl reads at runtime and Paraglide compiles at build time. Git stays the source of truth for what's deployed, and a Tolgee outage can never take rendering down.

The stack composes cleanly: Tolgee manages the strings, next-intl or Paraglide delivers them, and the pathname map from earlier localizes the URLs around them.

Missing translations are a state, not an error

Real projects are never 100% translated on launch day, and pretending otherwise produces either 404s or silent English leaking into the French site. The content model has to represent the gap: published is a per-locale property, not a global boolean.

Then the fallback policy is a business decision you make per content type, once, instead of a surprise per page:

  • Service pages, pricing, legal: must exist in every locale before the locale ships. No fallback — these are the pages that sell.
  • Blog and news: may fall back to the default language, visibly labelled, until translated.
  • Whatever falls back drops out of that locale's sitemap and hreflang set. Don't tell Google a French page exists when what's there is English.

On headless commerce builds the same policy extends into the ERP: product names and descriptions are translated fields in Odoo, maintained by the shop team next to prices and stock — the storefront just requests the locale it's rendering. One source of truth, no translation spreadsheet drifting out of sync.

Formatting is translation too

The message files are half the job; the other half is everything you're tempted to hardcode. Even locales sharing a currency don't agree on how to write it:

one Intl call instead of a hardcoded string
const eur = (locale: string, n: number) =>
  new Intl.NumberFormat(locale, { style: "currency", currency: "EUR" }).format(n)
 
eur("nl-BE", 1234.56) // "€ 1.234,56"
eur("de-DE", 1234.56) // "1.234,56 €"
eur("en-IE", 1234.56) // "€1,234.56"

Dates, number grouping, currency position: Intl knows, string concatenation doesn't. The team rule that makes this stick is blunt — no user-visible string outside the message files, no formatted value outside Intl — and it's enforced in code review like any other correctness rule.

More languages, zero extra latency

The old objection to i18n was weight: every visitor downloaded every translation plus a runtime to swap them. That objection is dead twice over. Server Components resolve next-intl messages during rendering, so the HTML arrives already in the right language and only interactive islands ship the handful of strings they use; Paraglide goes further and compiles the lookup away entirely.

Combined with static generation the whole thing disappears: generateStaticParams multiplied by four locales just means more static pages, and static pages don't get slower by existing. A page that exists in four languages is four pages — built once, served from your own cluster, each one as fast as a monolingual site.

Which is the pattern in one sentence: multilingual products go wrong as an afterthought and go smoothly as a day-one decision. Language in the URL, slugs in the translation map, detection demoted to a polite suggestion, gaps modelled instead of denied — with next-intl or Paraglide delivering the strings and a self-hosted Tolgee keeping the humans in sync. The retrofit version costs a multiple of the day-one version, and we've priced both often enough to know.

Want us to publish something specific?

Tell us what you'd like to read and we'll add it to our writing queue.

Get the next one in your inbox

New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.