Dev.to WebDev 🛠 Dev 👁 0 📖 2 min read

Generating Localised Price Tables From One JSON Source

A pricing page that exists in six languages usually ends up with six slightly different price tables. Someone updates the English plan limits, forgets the Japanese page, and now an AI assistant or a customer quotes the w

A pricing page that exists in six languages usually ends up with six slightly different price tables. Someone updates the English plan limits, forgets the Japanese page, and now an AI assistant or a customer quotes the wrong number. The fix is to treat prices as data and render every language version from one source.

One source of truth

{
  "currency_by_locale": {"en": "USD", "de": "EUR", "ja": "JPY", "pt-BR": "BRL"},
  "plans": [
    {"id": "starter", "price": {"USD": 29, "EUR": 27, "JPY": 4300, "BRL": 149},
     "seats": 3, "features": ["chat", "reports"]},
    {"id": "growth", "price": {"USD": 99, "EUR": 92, "JPY": 14800, "BRL": 499},
     "seats": 10, "features": ["chat", "reports", "api"]}
  ],
  "updated": "2026-10-31"
}

Prices per currency are set explicitly rather than converted on the fly. Rounded local prices look intentional, and you avoid tables that change every time an exchange rate moves.

Rendering with Intl

import data from "./pricing.json" assert { type: "json" };
import t from "./i18n.json" assert { type: "json" };

export function renderTable(locale) {
  const cur = data.currency_by_locale[locale];
  const fmt = new Intl.NumberFormat(locale, { style: "currency", currency: cur,
    maximumFractionDigits: cur === "JPY" ? 0 : 2 });
  const rows = data.plans.map(p => `
    <tr><th>${t[locale].plans[p.id]}</th>
      <td>${fmt.format(p.price[cur])} / ${t[locale].month}</td>
      <td>${p.seats} ${t[locale].seats}</td>
      <td>${p.features.map(f => t[locale].features[f]).join(", ")}</td></tr>`);
  return `<table>${rows.join("")}</table>
    <p>${t[locale].updated}: ${new Date(data.updated).toLocaleDateString(locale)}</p>`;
}

Intl.NumberFormat handles symbol position, separators and spacing, which differ more than people expect: €27,00 in some locales, 27,00 € in German, R$ 149,00 in Brazil.

Fail the build on missing data

Add a test that loops over every locale and plan and fails if a price or translation key is missing. It is the cheapest way to stop a half-updated table from shipping.

for (const loc of Object.keys(data.currency_by_locale))
  for (const p of data.plans) {
    if (p.price[data.currency_by_locale[loc]] == null) throw new Error(`${loc}:${p.id}`);
    if (!t[loc]?.plans?.[p.id]) throw new Error(`missing label ${loc}:${p.id}`);
  }

Why it matters beyond tidiness

Consistent, dated prices across language versions make your pricing page safer to quote. Search engines and AI assistants that read several versions of the page see the same facts, and buyers comparing currencies see a deliberate policy rather than a mistake.

Translation tooling is the other half of this workflow; this multilingual website translation tools comparison covers how teams split work between machine translation, plugins and human review.

Published by PandaWM, an overseas marketing team based in Hong Kong.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.