Skip to content

Insights

Building a 22-language Next.js platform: lessons from GAGA

Locale routing, Latin and Cyrillic Serbian, Intl formatting, fonts, translation CI and fresh exchange rates: lessons from a 22-language Next.js build.

sigmacode.io engineering team10 min read

On this page (11)
  1. Give every locale its own URL
  2. Two scripts, one language
  3. Format numbers, currencies and dates with Intl
  4. Fonts and glyph coverage
  5. A translation workflow that survives 22 languages
  6. Real-time data without stale values
  7. One source of truth for PDF, Excel, CSV and XML
  8. SEO across many locales
  9. Accessibility
  10. Testing: routes × locales
  11. What to plan for

GAGA Menjačnica is a currency exchange and precious metals dealer with several branches in Novi Sad, Serbia. Our team built their platform, menjacnicegaga.rs, with Next.js and React on Vercel. It runs in 22 languages, including Serbian in both Latin and Cyrillic script, German, Chinese, Russian, Turkish, Ukrainian, Greek and Bulgarian. It shows live buy and sell rates next to the National Bank of Serbia reference rates, and it includes a currency converter, rate lists you can download in five formats, a branch locator and guides to investment gold and silver.

Twenty-two languages is past the point where internationalisation is a feature. At that scale it shapes the architecture. This article covers what we think matters when you build a platform like this. It is written for CTOs, product owners and frontend engineers who are planning one. For the project itself, see the GAGA case study.

Give every locale its own URL#

The most important decision comes first: each language version of each page needs its own stable, crawlable URL. Don't switch languages with a cookie or by sniffing Accept-Language. Search engines can't index that, users can't share it, and CDNs can't cache it cleanly.

We recommend a locale prefix in the path, such as /sr/..., /de/... and /zh/.... With the Next.js App Router, that means a [locale] segment at the root of the app. The request proxy (proxy.ts, formerly middleware) can redirect a first visit to a sensible default based on the browser's language. After that, the URL is the single source of truth, and a language switcher has to link to the same page in the other locale, not to the other locale's homepage.

Plan your locale identifiers early and use BCP 47 tags throughout. Serbian alone needs two, sr-Latn and sr-Cyrl. Mapping those to URL segments, hreflang values and Intl locales in one place saves a lot of trouble later.

hreflang, x-default and sitemaps#

Every page should list all of its alternates, including itself, and add an x-default for users whose language you don't support. In the App Router, the metadata API does this for you:

ts
// app/[locale]/rates/page.tsx
import type { Metadata } from "next";

const BASE = "https://example.com";
const LOCALES = ["sr-Latn", "sr-Cyrl", "en", "de", "zh", "ru"] as const;
const segment = (l: string) => l.toLowerCase(); // "sr-Latn" -> "sr-latn"

export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
  const { locale } = await params;
  const path = "/rates";
  const languages: Record<string, string> = Object.fromEntries(
    LOCALES.map((l) => [l, `${BASE}/${segment(l)}${path}`])
  );
  languages["x-default"] = `${BASE}/en${path}`;

  return {
    alternates: { canonical: `${BASE}/${locale}${path}`, languages },
  };
}

Build your sitemap from the same locale list and the same route registry, with alternates for each entry. Once hreflang tags and sitemap entries come from separate code paths, they will eventually disagree, and search engines will quietly stop trusting both.

Two scripts, one language#

Serbian is written in both Latin and Cyrillic, and many readers have a strong preference. Treat them as two full locales, not as one locale with a display toggle. Each gets its own URL, its own hreflang value and its own lang attribute.

It's tempting to write everything in one script and transliterate the other automatically. For running text, a well-tested transliteration step can be a reasonable starting point. But don't apply it blindly to everything:

  • Proper nouns and brands. Company names, product names and foreign words often keep their Latin form even in Cyrillic text. A mechanical converter will "helpfully" turn them into something nobody wrote.
  • Digraph ambiguity. Latin nj, lj and usually map to single Cyrillic letters, but not always. Compound words and foreign loanwords break the rule. Going Cyrillic to Latin is deterministic. Going Latin to Cyrillic is not.
  • URLs, codes and identifiers. Currency codes like EUR, email addresses, slugs and anything inside interpolation placeholders must never be transliterated.
  • Search and sorting. Users may type Latin into a search box on a Cyrillic page. Normalise both sides before you compare.

What we recommend: store the Cyrillic source (the deterministic direction), or keep two separate catalogs, and keep a small exception list that a native speaker owns. Anything a machine produced should be reviewed before launch.

Format numbers, currencies and dates with Intl#

On an exchange-rate site, the numbers are the product. Serbian uses a comma as the decimal separator, German groups thousands with a dot, and Chinese users expect different conventions again. Don't format by hand. Use the built-in Intl APIs and pass the full locale tag:

ts
const rateFormatter = (locale: string) =>
  new Intl.NumberFormat(locale, {
    minimumFractionDigits: 4,
    maximumFractionDigits: 4,
  });

const moneyFormatter = (locale: string, currency: string) =>
  new Intl.NumberFormat(locale, { style: "currency", currency });

const asOf = (locale: string, date: Date) =>
  new Intl.DateTimeFormat(locale, {
    dateStyle: "long",
    timeStyle: "short",
    timeZone: "Europe/Belgrade",
  }).format(date);

rateFormatter("sr-Latn").format(117.1234);   // "117,1234"
rateFormatter("de").format(117.1234);        // "117,1234"
moneyFormatter("en", "EUR").format(1250);    // "€1,250.00"
asOf("sr-Cyrl", new Date());                 // e.g. "18. септембар 2026. 10:30" (exact output depends on the ICU version)

A few things to plan for. Set the time zone explicitly. Server-rendered pages otherwise use the server's zone, which is usually UTC. Create formatters once and reuse them, because constructing them repeatedly in a large table adds up. And keep the number of decimal places in the data layer as well as in the UI, so exports and the screen agree.

Fonts and glyph coverage#

Twenty-two languages means Latin, Latin Extended (Serbian, Croatian, Turkish), Cyrillic (Serbian, Russian, Ukrainian, Bulgarian), Greek and CJK characters. Few brand typefaces cover all of these, and the ones that do are large.

What matters:

  • Check coverage per script before you choose a typeface. Test real strings, not "Lorem ipsum". Serbian Cyrillic has its own letters and local italic forms, Ukrainian has letters that Russian lacks, and Bulgarian prefers its own glyph shapes.
  • Subset by script and load by locale. next/font supports subsets like latin, latin-ext, cyrillic and greek. A German page should not download Cyrillic glyphs.
  • Don't self-host a full CJK font on every page. Chinese fonts can be several megabytes. A system font stack for CJK, such as "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif, is often the right trade-off.
  • Design an explicit fallback stack and set metric-compatible fallbacks, so text doesn't shift when the web font loads.

A translation workflow that survives 22 languages#

With two or three languages, a spreadsheet and some discipline are enough. With 22, you need a pipeline.

Message catalogs with stable keys. Use one JSON file per locale, with keys named by meaning, like rates.table.buy, and never by English text. Use ICU MessageFormat for plurals and interpolation. Plural rules differ a lot: Russian, Ukrainian and Serbian have several forms, and Chinese has none.

Key parity in CI. A missing key in one locale is the most common bug in a multilingual site, and it's the easiest to catch automatically:

ts
// scripts/check-i18n.ts — run in CI, fail the build on drift
import { readdirSync, readFileSync } from "node:fs";

const dir = "messages";
const flatten = (o: Record<string, unknown>, p = ""): string[] =>
  Object.entries(o).flatMap(([k, v]) =>
    v && typeof v === "object" ? flatten(v as Record<string, unknown>, `${p}${k}.`) : [`${p}${k}`]
  );

const load = (f: string) => new Set(flatten(JSON.parse(readFileSync(`${dir}/${f}`, "utf8"))));
const source = load("en.json");
let failed = false;

for (const file of readdirSync(dir).filter((f) => f.endsWith(".json") && f !== "en.json")) {
  const keys = load(file);
  const missing = [...source].filter((k) => !keys.has(k));
  const extra = [...keys].filter((k) => !source.has(k));
  if (missing.length || extra.length) {
    failed = true;
    console.error(`${file}: missing ${missing.length}, extra ${extra.length}`, { missing, extra });
  }
}
process.exit(failed ? 1 : 0);

Extend the same script to check that interpolation placeholders match across locales. A translated placeholder name breaks at runtime, not at build time.

AI-assisted translation, with human review. Machine and LLM translation now produce good first drafts, and at 22 languages that changes the economics. But a currency exchange deals with money and trust. Rate labels, legal notices and investment guides should be reviewed by a fluent human before they go live. Give translators context: screenshots, character limits and a glossary of fixed terms such as "buy rate", "sell rate" and "reference rate".

Real-time data without stale values#

Exchange rates change during the day. The trap is caching them as aggressively as the rest of a static-first Next.js site.

General strategies we recommend:

  • Separate the shell from the data. The page layout, translations and guides can be static or revalidated rarely. The rates table should have its own short revalidation window, or be fetched on the client or streamed.
  • Keep revalidation short and deliberate. Choose a window that matches how often the rates actually change, and document it. Where possible, use on-demand revalidation when new rates are published, rather than relying only on a timer.
  • Cache at the edge with care. Short s-maxage with stale-while-revalidate keeps pages fast, but make sure the stale window is one the business can accept.
  • Always show an "as of" timestamp, formatted per locale in the branch's time zone. It's the honest answer to the question every caching system raises: how fresh is this?

One source of truth for PDF, Excel, CSV and XML#

GAGA publishes its rate list for download as PDF, JPG, Excel, CSV and XML. Five formats create five chances for the numbers to disagree.

The rule: build every export from the same normalised data structure, the same one that renders the on-screen table. Put rounding, ordering and currency metadata in that structure, not in each exporter. Each format then becomes a thin serializer. Things to plan for:

  • CSV needs a stated delimiter and encoding. Excel in many European locales expects a semicolon and handles UTF-8 better with a BOM.
  • Excel should get real numeric cells with number formats, not pre-formatted strings, so users can calculate with them.
  • XML needs a stable, documented schema, because someone will integrate against it.
  • PDF and images have to embed fonts that cover the requested script, which brings you back to glyph coverage.

Put the "as of" timestamp in every export as well.

SEO across many locales#

Beyond hreflang and sitemaps:

  • Translate titles, descriptions and Open Graph metadata per locale. Don't leave English metadata on a Greek page.
  • Localise slugs only if you can keep them stable. A changed slug in one locale means redirects and a broken hreflang cluster.
  • Avoid thin duplicates. If a locale only has partial content, consider whether it should be indexed yet.
  • Canonical URLs should point to the page itself, never to another language version.

Accessibility#

Set lang on the html element for every locale, using the full tag (sr-Latn, sr-Cyrl), so screen readers pick the right voice and pronunciation. When a phrase in another language appears inside a page, mark it with its own lang.

Even if you don't support a right-to-left language today, plan for it. Use CSS logical properties such as margin-inline-start instead of margin-left, and derive dir from the locale. Adding Arabic later is far cheaper when the layout doesn't assume left-to-right.

Testing: routes × locales#

With 22 locales, a bug that shows up in only one of them is easy to miss. We recommend an automated smoke test that loops over every public route and every locale and checks the basics:

  • the page returns 200 and renders without runtime errors;
  • html has the correct lang;
  • hreflang alternates are complete and point to URLs that exist;
  • no raw message keys (like rates.table.buy) or empty strings are visible;
  • the converter and rate table render numbers in the expected format.

Add visual snapshots for the longest languages. German and Greek labels often break layouts that looked fine in English.

What to plan for#

If you are starting a multilingual Next.js platform, these are the decisions to make on day one: a locale-prefixed URL strategy, one registry of locales that drives routing, metadata and sitemaps, a clear policy for scripts and transliteration, Intl for all formatting, a font plan per script, CI checks for translation parity, an explicit freshness policy for live data, and a single source of truth for every export.

None of this is exotic, but it is much cheaper to design in than to retrofit. Our work is led by a tech lead with 20+ years of experience, and this is the kind of groundwork we focus on in Web & Platforms. If you're planning something similar, get in touch.

Have a project in mind?

Tell us what you're building. You get an honest assessment, a clear scope and a fixed-price or milestone proposal, usually within a few working days.

Prefer to write first? Write to us