A simple i18n strategy for single-page apps

I built nokkam — a Kerala election prediction game — and a week before the April 9 elections, I wanted to add Malayalam. I looked at react-i18next and FormatJS. Both are built for apps that need plural rules, date formatting, server-side loading, and right-to-left support. Nokkam has none of that. It is a two-language SPA with static strings and no backend.

I did not want to wire up a plugin system for a problem that did not need one. So I wrote the i18n layer myself. Here is what I needed:

  • Two languages only
  • Type safety: if ml.ts is missing a key that en.ts has, fail at compile time, not runtime
  • Language preference persists across page reload
  • No build step, no code generation

That is it. The result is 60 lines.

The type trick

The English file is a plain object. The last line is the important one:

// src/locales/en.ts
export const en = {
  splash: {
    title: "Kerala Assembly Election\nPrediction Game",
    tap: "Tap to begin",
  },
  leaderboard: {
    predictionSingular: "prediction",
    predictionPlural: "predictions",
  },
  constituencies: {
    1: "Manjeshwara",
    2: "Kasaragod",
    // ... 140 entries
  } as Record<number, string>,
  districts: {
    Kasaragodu: "Kasaragodu",
    // ... 14 entries
  } as Record<string, string>,
};

export type Translations = typeof en;

typeof en generates a type from the object’s shape. The Malayalam file imports that type and must satisfy it:

// src/locales/ml.ts
import type { Translations } from "./en";

export const ml: Translations = {
  splash: {
    title: "കേരള നിയമസഭാ തിരഞ്ഞെടുപ്പ്\nപ്രവചന മത്സരം",
    tap: "തുടങ്ങാൻ ടാപ്പ് ചെയ്യുക",
  },
  leaderboard: {
    predictionSingular: "പ്രവചനം",
    predictionPlural: "പ്രവചനങ്ങൾ",
  },
  // ...
};

If I add a key to en.ts and forget ml.ts, TypeScript throws a compile error. No runtime surprises, no JSON schema files, no code generation step.

One thing to avoid: as const on the en object. That makes every string a literal type, so ml.ts cannot satisfy Translations — every Malayalam string would need to match the English one character for character.

The context

The provider reads the saved preference from localStorage on mount, then exposes the current translations and a toggle function:

// src/lib/i18n.tsx
import { createContext, useContext, useState, type ReactNode } from "react";
import { en } from "../locales/en";
import { ml } from "../locales/ml";
import type { Translations } from "../locales/en";

export type Lang = "en" | "ml";

const LOCALES: Record<Lang, Translations> = { en, ml };
const STORAGE_KEY = "nokkam_lang";

interface I18nCtx {
  tr: Translations;
  lang: Lang;
  toggleLang: () => void;
  tC: (num: number) => string;
  tD: (name: string) => string;
}

const Ctx = createContext<I18nCtx | null>(null);

export function I18nProvider({ children }: { children: ReactNode }) {
  const [lang, setLang] = useState<Lang>(() => {
    const stored = localStorage.getItem(STORAGE_KEY);
    return stored === "ml" ? "ml" : "en";
  });

  const tr = LOCALES[lang];

  function toggleLang() {
    const next: Lang = lang === "en" ? "ml" : "en";
    localStorage.setItem(STORAGE_KEY, next);
    setLang(next);
  }

  function tC(num: number): string {
    return tr.constituencies[num] ?? en.constituencies[num] ?? String(num);
  }

  function tD(name: string): string {
    return tr.districts[name] ?? name;
  }

  return (
    <Ctx.Provider value=>
      {children}
    </Ctx.Provider>
  );
}

export function useTranslation(): I18nCtx {
  const ctx = useContext(Ctx);
  if (!ctx) throw new Error("useTranslation used outside I18nProvider");
  return ctx;
}

A component calls useTranslation() and reads from tr:

// src/screens/SplashScreen.tsx
export function SplashScreen({ onNext }: Props) {
  const { tr } = useTranslation();
  const [line1, line2] = tr.splash.title.split("\n");
  return (
    <div className="splash-screen" onClick={onNext}>
      <h1>{line1}<br />{line2}</h1>
      <p>{tr.splash.tap}</p>
    </div>
  );
}

The toggle button shows “മ” in English mode and “EN” in Malayalam:

// src/components/LangToggle.tsx
export function LangToggle() {
  const { lang, toggleLang } = useTranslation();
  return (
    <button onClick={toggleLang} aria-label="Toggle language">
      {lang === "en" ? "" : "EN"}
    </button>
  );
}

The proper nouns problem

Kerala has 140 assembly constituencies and 14 districts. These are proper nouns — each has a canonical Malayalam spelling that differs from transliteration, and they are looked up by ID, not by a developer-chosen key.

i18n libraries handle this badly. They assume every string has a developer-chosen key.

The solution here is two separate maps inside the locale file, typed with Record<number, string> and Record<string, string>. The tC(num) and tD(name) helpers look up those maps and fall back to English if the key is missing — safe for incremental rollout.

The translation workflow

I wrote the first pass myself, with Claude helping inline. Then I embedded a review prompt as a block comment at the bottom of ml.ts and pasted the whole file into a separate chat.

The corrections that came back:

My version Correct
കാസർഗോഡ് കാസർകോട് (official district name)
ആടൂർ അടൂർ (constituency #115)
അറ്റിങ്ങൽ ആറ്റിങ്ങൽ (Attingal)
അരൻമുള ആറന്മുള (Aranmula)
നടപ്പുറം നാദാപുരം (Nadapuram)

The review also made several UI strings more colloquial.

The workflow is reproducible: write the review prompt once, embed it in the source, paste the file whenever you want a pass. It works for any language.

Where this breaks down

More than two or three languages, and the object approach stops scaling. Malayalam has complex plural forms — the current setup handles it with separate keys (predictionSingular, predictionPlural), which works but is not general. A proper i18n library handles pluralisation with a rules engine. Server-rendered apps need a different loading strategy. For ICU message format, date and number localisation, or RTL support: use a library.

For a small SPA with a fixed set of languages and no pluralisation complexity, this is enough.