AI Computer Institute
Expert-curated CS & AI curriculum aligned to CBSE standards. A bharath.ai initiative. About Us

Internationalization: Supporting Multiple Languages

📚 Frontend⏱️ 23 min read🎓 Grade 9
✍️ AI Computer Institute Editorial Team Updated: September 2026 CBSE-aligned · Reviewed for accuracy · 23 min read
Mapped to the CBSE/NCERT syllabus and reviewed for accuracy in a separate pass. Spotted an error? Tell us on the contact page.

Suppose you and a classmate build a train-ticket practice app for a school project — something like a toy version of IRCTC. It shows messages like "1 seat left", prices like "Fare: 2450", and dates like "Journey: 15/8/2026". Your teacher likes it enough to put it on the school website. Within a week, three things go wrong. A cousin in Chennai whose grandmother only reads Tamil can't use it at all. A relative in London sees "Fare: 2450" and briefly thinks it costs two thousand four hundred fifty dollars, because nothing on screen says it's rupees. And a friend who tested it in Hindi noticed the app cheerfully said "1 seats left" — grammatically wrong, because the code always appended "s" without checking the number. None of these are bugs in the ticket-booking logic. They are all the same underlying problem: the code assumes one language, one number format, one currency, one way of writing dates. That assumption is baked into the program itself, not just into the text on screen.

This chapter is about the discipline of writing code that does not make that assumption — a practice called internationalization, almost always abbreviated i18n (the word has 20 letters; there are 18 letters between the first "i" and the last "n", so programmers write "i18n" as a shorthand). It is one of the most practical, testable topics in front-end programming, because every piece of it — string lookup, pluralization, number grouping, dates, currency — is a small, precise, traceable algorithm. That is exactly the kind of code we can reason about line by line, the same way you'd trace a loop or a recursive function for a CBSE Computer Science exam.

The problem with hardcoded text

Here is the naive way almost everyone starts:

function greet(name) {
  return "Hello, " + name + "!";
}

console.log(greet("Aisha")); // "Hello, Aisha!"
console.log(greet("Meera")); // "Hello, Meera!"

This works perfectly — for English speakers. The word "Hello" is stitched permanently into the function's logic. If you want to greet someone in Hindi, you cannot just "translate the app"; you have to open greet(), find every hardcoded string inside it, and either edit it in place (which destroys the English version) or write an entirely separate greetHindi() function. Now imagine your app has 40 such strings spread across 15 functions, and you need to support three languages. You would end up maintaining 120 near-duplicate functions that differ only in their quoted text, and every future bug fix would need to be repeated 40 times. This does not scale, and it is not really a translation problem — it is a software design problem: the words and the logic are welded together when they should be separable.

The fix: separate the words from the logic

The core idea of internationalization is simple to state: never write a user-facing word directly into your logic. Instead, give every piece of text a key — a short, stable, language-independent name — and look up the actual words for the user's chosen language from a table.

const strings = {
  en: { greeting: "Hello", logout: "Log out" },
  hi: { greeting: "नमस्ते", logout: "लॉग आउट" }
};

function t(locale, key) {
  return strings[locale][key];
}

console.log(t("en", "greeting")); // "Hello"
console.log(t("hi", "greeting")); // "नमस्ते"
console.log(t("hi", "logout"));   // "लॉग आउट"

Trace the second call carefully, because this pattern reappears in every remaining example in this chapter. t("hi", "greeting") runs strings["hi"]["greeting"]. First, strings["hi"] picks out the inner object { greeting: "नमस्ते", logout: "लॉग आउट" }. Then ["greeting"] picks out the value "नमस्ते" from that object. The function greet() itself never needs to know any actual words — it only needs to know the key "greeting", and the correct word for whichever language the user has selected gets substituted in. t is conventionally short for "translate," and this exact pattern — a nested lookup table keyed first by locale, then by string name — is what every production translation library (react-i18next, FormatJS, Vue I18n) does under the hood, just with more features layered on top.

If you have used dictionaries in Python in your CBSE Computer Science classes, strings is doing exactly the same job as a Python dict of dicts — JavaScript just calls the structure an "object" and looks up values the same way, with square brackets and a key.

Common misconception: "internationalization just means translating the text"

This is the single most common misunderstanding students carry into this topic, and it is worth naming precisely so you can catch yourself making it. Translating words is only one part of the job, and usually the easiest part. A program is properly internationalized only when its code — not just its word list — can adapt to a locale. That includes at least four things that have nothing to do with vocabulary: how numbers are grouped into digits (12,345,678 versus 1,23,45,678), how a sentence changes shape depending on a count ("1 message" versus "2 messages" versus, in some languages, five or six different shapes), how dates order day, month and year, and which direction text flows across the screen — English and Hindi both run left to right, but Urdu and Arabic run right to left, which is why real interfaces set a dir="rtl" attribute rather than hardcoding left-to-right layout in their CSS. A translator can hand you a perfect Hindi word list, and your app can still be broken for Hindi users if the code underneath still assumes English-style number grouping and English-style pluralization. Internationalization is a property of the code's design; translation is a property of the content. The industry even uses two separate words for this: i18n for preparing the code to support any locale, and l10n ("localization" — 12 letters, 10 between the "l" and the "n") for the actual act of supplying translated content for one specific locale. You do i18n once, in the code. You do l10n repeatedly, once per language you add.

Filling in the blanks: placeholders

Real interface text is rarely a fixed phrase — it usually has a name or a number stitched into the middle, like "Welcome back, Rohan!" To keep this translatable, the stored template keeps a placeholder instead of gluing the value in with string concatenation:

const strings2 = {
  en: { welcomeMsg: "Welcome back, {name}! You have {count} new messages." }
};

function format(template, values) {
  return template.replace(/\{(\w+)\}/g, (match, key) => values[key]);
}

const msg = format(strings2.en.welcomeMsg, { name: "Rohan", count: 3 });
console.log(msg);
// "Welcome back, Rohan! You have 3 new messages."

Trace this line by line. template is the string "Welcome back, {name}! You have {count} new messages.". The regular expression /\{(\w+)\}/g looks for a literal {, followed by one-or-more "word" characters captured in a group, followed by a literal } — in plain words, "find every {something}." The g flag means "keep finding matches across the whole string, not just the first one." .replace() calls the arrow function once for every match it finds. On the first match, match is the full text "{name}" and key is just "name" (the captured group); the function returns values["name"], which is "Rohan". On the second match, key is "count", and values["count"] is the number 3, which JavaScript automatically converts to the text "3" when it's spliced into the string. The result is "Welcome back, Rohan! You have 3 new messages." — exactly what the comment claims. Notice why this design matters: a translator working on the Hindi version only ever edits the template string and can move {name} anywhere the grammar of Hindi requires it — they never touch format() itself.

When words change shape with the count: pluralization

Look again at that template: "You have {count} new messages." It is wrong when count is 1 — "You have 1 new messages" is bad English grammar. The tempting fix is an if-statement that appends "s" when the count isn't 1, but that only works for English. Many languages don't follow a simple singular/plural split at all.

Before writing real code, here is the idea in plain notation — this next block is not runnable JavaScript, it is just describing, in comments, what a plural-selection function should conceptually return for different inputs:

// Conceptual notation only — every line below is a comment.
// It describes what a plural-selection function should return.
//
//   plural(0)  → "other"
//   plural(1)  → "one"
//   plural(2)  → "other"
//   plural(15) → "other"

JavaScript ships a real, built-in object that does exactly this job, using the actual grammar rules of whichever language you name — Intl.PluralRules:

const pluralRules = new Intl.PluralRules("en-IN");

console.log(pluralRules.select(0));  // "other"
console.log(pluralRules.select(1));  // "one"
console.log(pluralRules.select(2));  // "other"
console.log(pluralRules.select(15)); // "other"

.select(n) does not return the actual word "one" or "message" — it returns a category name that English happens to call "one" and "other." That distinction matters, because not every language has just two categories. Arabic, for example, has six:

console.log(new Intl.PluralRules("ar").select(0));   // "zero"
console.log(new Intl.PluralRules("ar").select(1));   // "one"
console.log(new Intl.PluralRules("ar").select(2));   // "two"
console.log(new Intl.PluralRules("ar").select(6));   // "few"
console.log(new Intl.PluralRules("ar").select(11));  // "many"
console.log(new Intl.PluralRules("ar").select(100)); // "other"

Hindi, like English, uses only two categories ("one" and "other"), but the point of the Arabic example is that your code must never assume the number of categories is fixed at two — it has to ask Intl.PluralRules what category a given count falls into for a given language, and then look up the matching sentence shape. Combining this with the format() function from before gives a genuinely reusable pluralization pipeline:

const messageForms = {
  en: {
    one: "You have {count} new message.",
    other: "You have {count} new messages."
  }
};

function pluralize(locale, count) {
  const category = new Intl.PluralRules(locale).select(count);
  const template = messageForms[locale][category];
  return format(template, { count });
}

console.log(pluralize("en", 1)); // "You have 1 new message."
console.log(pluralize("en", 5)); // "You have 5 new messages."

Trace pluralize("en", 1): category becomes new Intl.PluralRules("en").select(1), which is "one". template becomes messageForms["en"]["one"], which is "You have {count} new message.". Then format() substitutes {count} with 1, giving "You have 1 new message." — grammatically correct, and it never needed an if-statement written by hand.

Numbers don't read the same way everywhere

Even when every word is translated and every sentence pluralizes correctly, numbers themselves are written differently across regions. Read this number out loud: 12345678. Most students taught the international system read it as "twelve million, three hundred forty-five thousand, six hundred seventy-eight," and write it with commas every three digits: 12,345,678. But the Indian numbering system, used throughout Indian newspapers, banking, and government forms, groups digits differently: it separates the last three digits as usual, but every group after that is only two digits wide, not three. Written out, 12345678 becomes 1,23,45,678 — read aloud as "one crore, twenty-three lakh, forty-five thousand, six hundred seventy-eight," where 1 crore = 1,00,00,000 and 1 lakh = 1,00,000.

The diagram below shows exactly where each comma lands under both systems, for this same eight-digit number.

Digit Grouping: 12345678 in Two Systems International (en-US): groups of 3 digits from the right 12 , 345 , 678 millions thousands ones Indian (en-IN): last 3 digits, then pairs of 2 digits 1 , 23 , 45 , 678 crore lakh thousand hundred = one crore twenty-three lakh forty-five thousand six hundred seventy-eight 12345678 → International: 12,345,678 | Indian: 1,23,45,678

Now let's build the grouping algorithm ourselves, so the rule is fully explicit rather than a fact to memorize. The idea in words: peel off the last three digits as one group, then repeatedly peel off two digits at a time from whatever remains, until one or two digits are left over at the front.

function toIndianGrouping(numStr) {
  const lastThree = numStr.slice(-3);
  let rest = numStr.slice(0, -3);
  let groups = [];

  while (rest.length > 2) {
    groups.unshift(rest.slice(-2));
    rest = rest.slice(0, -2);
  }
  if (rest.length > 0) groups.unshift(rest);

  return groups.length > 0 ? groups.join(",") + "," + lastThree : lastThree;
}

console.log(toIndianGrouping("12345678")); // "1,23,45,678"

Trace it fully for "12345678". lastThree = "678". rest = "12345" (five characters). groups = []. First loop check: rest.length is 5, which is greater than 2, so the loop body runs: rest.slice(-2) is "45", unshift puts it at the front of groups, giving groups = ["45"]; then rest becomes rest.slice(0,-2), which is "123". Second loop check: rest.length is 3, still greater than 2, so it runs again: rest.slice(-2) is "23", unshift gives groups = ["23", "45"], and rest becomes "1". Third loop check: rest.length is 1, which is not greater than 2, so the loop stops. Since rest.length (1) is greater than 0, we unshift(rest) one final time, giving groups = ["1", "23", "45"]. Finally, groups.join(",") produces "1,23,45", and appending "," + lastThree gives "1,23,45,678" — matching the diagram exactly.

You would never actually ship this hand-written function in real code, because JavaScript's built-in Intl.NumberFormat already knows every country's grouping rule:

const western = new Intl.NumberFormat("en-US");
const indian  = new Intl.NumberFormat("en-IN");

console.log(western.format(12345678)); // "12,345,678"
console.log(indian.format(12345678));  // "1,23,45,678"

Writing toIndianGrouping yourself was still worth doing — it is the difference between memorizing "Indian numbers use lakh and crore" as a fact and actually understanding the digit-by-digit rule that produces it, which is exactly the kind of algorithmic thinking board exams test with dry-run and trace-the-output questions.

Currency: same digits, different symbol and precision

Currency formatting layers two more decisions on top of digit grouping: which symbol to show, and how many decimal places to keep (most currencies use two, but not all — the Japanese yen conventionally uses zero). Intl.NumberFormat handles this with a style and currency option:

const priceINR = new Intl.NumberFormat("en-IN", {
  style: "currency",
  currency: "INR"
});

console.log(priceINR.format(1234567)); // "₹12,34,567.00"
console.log(priceINR.format(1500.5));  // "₹1,500.50"

Notice that the grouping still follows the Indian rule from the previous section (₹12,34,567.00 — pairs of two after the first three), because the grouping behavior belongs to the locale "en-IN", not to the currency itself. If you formatted the same rupee amount with the locale set to "en-US", you would still get rupees (₹), but grouped as ₹1,234,567.00 — the currency and the digit-grouping convention are two independent settings that happen to usually travel together, which is worth noticing precisely because it's the kind of detail a rushed implementation gets wrong.

Dates: the order of day, month and year isn't fixed either

The same date can be printed in genuinely different orders depending on locale, even when you ask for the identical level of detail:

const date = new Date(2026, 7, 15); // August 15, 2026 (month is 0-indexed: 7 = August)

const opts = { year: "numeric", month: "long", day: "numeric" };

console.log(new Intl.DateTimeFormat("en-IN", opts).format(date)); // "15 August 2026"
console.log(new Intl.DateTimeFormat("en-US", opts).format(date)); // "August 15, 2026"

Both calls pass the exact same date and the exact same opts object — asking for a numeric year, a fully spelled-out month, and a numeric day. The only thing that changes is the locale string, and yet the order of the three pieces flips, and the punctuation differs (no comma in the Indian form, a comma in the US form). This is precisely why hand-building date strings with concatenation — day + "/" + month + "/" + year — is a design mistake even for developers who only ever expect to ship to one country: the moment the product needs a second locale, that hardcoded order has to be found and reworked everywhere it appears, whereas code that calls Intl.DateTimeFormat with a locale variable adapts automatically.

Putting the whole pipeline together

Every technique above — keyed lookup, placeholder substitution, plural category selection, and locale-aware number formatting — combines naturally into a single function that renders a real notification message for a real user:

const catalog = {
  "en-IN": {
    welcomeMsg: "Welcome back, {name}!",
    messageForms: {
      one: "You have {count} new message.",
      other: "You have {count} new messages."
    }
  },
  "hi-IN": {
    welcomeMsg: "वापसी पर स्वागत है, {name}!",
    messageForms: {
      one: "आपका {count} नया संदेश है।",
      other: "आपके पास {count} नए संदेश हैं।"
    }
  }
};

function renderNotification(locale, name, count, amountPaise) {
  const data = catalog[locale];

  const welcomeLine = format(data.welcomeMsg, { name });

  const category = new Intl.PluralRules(locale).select(count);
  const messageLine = format(data.messageForms[category], { count });

  const amountLine = new Intl.NumberFormat("en-IN", {
    style: "currency",
    currency: "INR"
  }).format(amountPaise / 100);

  return welcomeLine + " " + messageLine + " Balance due: " + amountLine;
}

console.log(renderNotification("en-IN", "Rohan", 1, 150050));
// "Welcome back, Rohan! You have 1 new message. Balance due: ₹1,500.50"

Trace the call end to end. data becomes catalog["en-IN"]. welcomeLine calls format("Welcome back, {name}!", { name: "Rohan" }), which — exactly as traced earlier in this chapter — substitutes {name} with "Rohan", giving "Welcome back, Rohan!". category calls new Intl.PluralRules("en-IN").select(1), which is "one". messageLine then calls format("You have {count} new message.", { count: 1 }), giving "You have 1 new message.". For amountLine, amountPaise is 150050 (the amount stored in paise, the way real payment systems store rupee amounts as whole integers to avoid decimal rounding errors), so amountPaise / 100 is 1500.5 rupees, and formatting that as Indian-grouped currency gives "₹1,500.50". The final string concatenates all three pieces with spaces and the fixed label "Balance due: ", producing exactly "Welcome back, Rohan! You have 1 new message. Balance due: ₹1,500.50". Every one of the four internationalization techniques from this chapter — lookup by key, placeholder substitution, plural-aware sentence selection, and locale-aware number formatting — is doing real, necessary work in that one line of output, and swapping the locale argument to "hi-IN" would change every piece of it correctly without touching a single line of the function itself.

Check your understanding

  1. Why does the naive greet() function from the start of this chapter fail to scale, even though it works correctly for English users?
  2. A developer writes if (count === 1) { text += " message"; } else { text += " messages"; } to handle pluralization for a multi-language app. Explain precisely why this breaks for a language like Arabic.
  3. Trace toIndianGrouping("987654321") by hand, showing the value of rest and groups after each loop iteration, and state the final returned string.
  4. What is the difference between internationalization (i18n) and localization (l10n)? Give one concrete example of work that belongs to each.
  5. Why does new Intl.NumberFormat("en-US", { style: "currency", currency: "INR" }).format(1234567) still show the rupee symbol, but with commas placed differently than Intl.NumberFormat("en-IN") would?

Summary

  • Internationalization (i18n) means designing code so it can adapt to any locale; localization (l10n) means supplying the actual translated content for one specific locale. Translating text is only l10n — i18n is the harder, structural half of the job.
  • Never write user-facing words directly inside logic. Store them in a lookup table keyed by locale and a stable string key, and read them through a small helper function like t(locale, key).
  • Placeholders ({name}, {count}) combined with a format() function let translators reorder or restructure a sentence without ever touching the surrounding code.
  • Intl.PluralRules selects a language-specific grammatical category for a number — not every language has just "singular" and "plural"; Arabic has six categories, English and Hindi have two.
  • Digit grouping is not universal: the international system groups by threes throughout (12,345,678); the Indian system groups the last three digits together, then by twos (1,23,45,678, read as crore/lakh/thousand). Intl.NumberFormat applies the correct rule automatically once you pass the right locale string.
  • Currency formatting (symbol, decimal places) and digit grouping are separate settings that are configured together through Intl.NumberFormat's style, currency, and locale arguments.
  • Intl.DateTimeFormat shows that identical date-detail options can still produce different day/month/year orderings and punctuation across locales — a strong argument against hand-built date strings.
  • Text direction is also a locale property: English and Hindi are left-to-right; Urdu and Arabic are right-to-left, handled with a dir="rtl" attribute rather than hardcoded layout assumptions.
← Accessibility: Building Inclusive Web AppsProgressive Enhancement: Graceful Degradation →

Found this useful? Share it!

📱 WhatsApp 🐦 Twitter 💼 LinkedIn
Share