DEV Community

Cover image for Localising your React app the "Tailwind" way: no keys & no JSON, just code
Amer Kočan
Amer Kočan

Posted on

Localising your React app the "Tailwind" way: no keys & no JSON, just code

So, you want to localise your app

Most i18n solutions push you towards a familiar pattern:

  • Giant JSON/YAML files
  • Tediously "naming" your translations via keys
  • Made up string interpolation syntax
  • Constantly jumping back and forth between UI code and translation files

Which works fine, but the ceremony can start to feel a little tedious after a while.

I remember a few years ago, when I first picked up TailwindCSS, I was shocked by how much faster I could ship real UI once I started writing only what was needed.

I wanted the same feeling for i18n.

I want to introduce: react-scoped-i18n 🌐

The core idea here is: instead of global translation files and keys, translations live right next to the components that render them.

How does i18n typically look?

// en.json
{
  "profile": {
    "header": "Hello, {{name}}"
  }
}

// es.json
{
  "profile": {
    "header": "Hola, {{name}}"
  }
}

// Header.tsx
export const Header = () => {
  const { t } = useI18n();

  return <h1>
    {t("profile.header", { name: "John" })}
  </h1>;
};
Enter fullscreen mode Exit fullscreen mode

Now, don't get me wrong, there's nothing wrong with this. It works and it's battle tested. But I kept running into the same friction:

  • Typescript can't help much without a lot of extra tooling
  • Translation keys give very little context about what actually renders
  • You cannot search the codebase for rendered text and find the component using it directly
  • String interpolation is custom syntax

So what does the “scoped” approach look like?

// Header.tsx
export const Header = () => {
  const { t } = useI18n();

  const name = "John";

  return (
    <h1>
      {t({
        en: `Hello, ${name}`,
        es: `Hola, ${name}`,
      })}
    </h1>
  );
};
Enter fullscreen mode Exit fullscreen mode

No keys, no giant JSON files, no custom interpolation syntax, just code!

And, to be clear: This isn't meant to replace full-blown i18n platforms. It's an alternative for code-driven apps, and getting your app translated and localised fast.


The biggest benefits of this approach are:

- Typesafety 🩵

Since it's just code, everything is typesafe.

As an example here, I initialised the i18n context and specified I will be localising to English, Spanish and Slovenian:

createI18nContext({
  languages: [`en`, `es`, `sl`],
  fallbackLanguage: `en`, // already statically typed (inferred from 'languages' above
});
Enter fullscreen mode Exit fullscreen mode

Later, when localising a component, I forgot the Spanish translation. This resulted in a Property 'es' is missing in type { en: string; sl: string; } typescript error , pictured here:

Screenshot showcasing the above-mentioned typescript error

The main takeaway: missing translations or unsupported languages become compile-time errors instead of runtime surprises 😄.

- No naming of keys

The content is the key. Being able to search for rendered text and land directly in the component turns out to be a huge DX win. 😊

- No additional build steps

Since this is running directly within the React Context ecosystem, the setup takes about 20 seconds. There are no build steps to get this running. Just install the library, initialise the context, wrap your app with the exported provider and boom!

- Out of the box number, date, time & currency formatting ‼️

This library utilises the widely supported Internationalization API (Intl) for number, currency, date and time formatting. This means that there as long as you are following standard locale identifiers (en, en-UK, es, es-ES, etc.), you have all the various formatters available to you with zero extra configuration.

Typesafe "shared" translations

For common strings shared throughout the app, you can still define shared translations through the commons api:

createI18nContext({
  languages: ["en", "es"],
  fallbackLanguage: "en",
  commons: {
    continue: {
      en: "Continue",
      es: "Continuar",
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

And then access them (in a typesafe way that works well with your IDE's autocomplete):

<Button>{t(commons.continue)}</Button>;
Enter fullscreen mode Exit fullscreen mode

Pluralisation is not ICU-based, but only ICU-inspired

Instead of enforcing strict ICU plural rules, tPlural lets each language define only the categories it actually needs.

{tPlural(count, {
  en: {
    one: `You have one apple.`,
    many: `You have ${count} apples.`,
  },
  sl: {
    one: `Imaš eno jabolko.`,
    two: `Imaš dve jabolki.`, // 👈 handling dual form in Slovenian that English does not have!
    many: `Imaš ${count} jabolk.`,
  },
})}
Enter fullscreen mode Exit fullscreen mode

and you can also target specific values, if need be:

{tPlural(count, {
  en: {
    one: `You have one apple.`,
    many: `You have ${count} apples.`,
    42: `You have THE PERFECT amount of apples!` // 👈 target specific values outside of ICU category specification
  },
  es: {
    one: `Tienes una manzana.`,
    many: `Tienes ${count} manzanas.`,
  },
})}
Enter fullscreen mode Exit fullscreen mode

When this isn't a good fit

This approach is developer-centric by design, so, if your workflow relies on:

  • external translators
  • "Crowdin" / "Lokalise" / similar tools
  • non-technical editors touching translation files
  • you have A LOT of supported languages

then this probably isn't the right approach.

But, if translations are written and maintained in code, and your supported language set is small-to-medium, the DX ends up being surprisingly nice.


If you're curious, the project is open source. You can check it out:

Happy to hear any feedback!

Top comments (2)

Collapse
 
0x1 profile image
Kevin 心学

You can also hybridize and define both the English version and the unique identifier of the translation dictionary (which has no English version inside). This is useful when there are many languages.

Collapse
 
akocan98 profile image
Amer Kočan • Edited

yeah, great idea! you would lose out on one of the benefits (not having to name your keys), but like you said, if there are many languages, this pattern would work great, since the other benefits are still retained! 💪

if you decide to check out (github)react-scoped-i18n, let me know what you think (and how the dev experience of your suggested pattern feels ❤️)