Back

i18n without translation files

Jackie Chen avatarJackie Chen
guideinternationalizationnextjsi18ngt-nexttranslation-filesdeveloper-experience

Everyone who's internationalised a JavaScript app knows the workflow. You install an i18n library, create an en.json file, pull every user-facing string out of your components, assign each one a key, and reference the key where the string used to be. Then you duplicate that JSON file for every language you support. es.json, fr.json, ja.json.

At first, it's fine. Thirty strings, three languages, 90 entries.

Then your app grows. Six months later you have 400 strings and 12 languages. 4,800 entries across a dozen files. A developer adds a new feature, writes five new strings, forgets to update three of the translation files. Nobody notices until a user in Tokyo sees English fallbacks in a Japanese interface. Someone suggests buying a translation management system.

The file management tax

Translation files create a maintenance burden that scales with the number of languages you support. Change a string and you need to update a dozen files. Those files drift out of sync. Keeping them aligned requires tooling, automation or discipline.

The ecosystem of workarounds tells the story: VS Code extensions that auto-generate translation keys, type generators that validate your JSON against TypeScript interfaces, linters for unused keys, CI checks that verify all locales are in sync. You don't build this kind of tooling for a workflow that works well.

Your UI says one thing, your translation files say another, and a key-based mapping holds the two together:

function CheckoutSummary({ itemCount, discount }) {
  const { t } = useTranslation('checkout');

  return (
    <div>
      <h2>{t('summary.title')}</h2>
      <p>{t('summary.item_count', { count: itemCount })}</p>
      {discount && <p>{t('summary.discount_applied', { percent: discount })}</p>}
      <p>{t('summary.total_label')}</p>
      <small>{t('summary.tax_notice')}</small>
    </div>
  );
}

To understand what this component renders, you'd have to open checkout.json, find the summary namespace, and cross-reference five keys. Across hundreds of components, code review becomes an exercise in jumping between files.

ICU message syntax makes this worse. The format libraries like i18next, react-intl, and next-intl use for plurals, gender, and interpolation is its own mini-language embedded in JSON strings. A misplaced brace in {count, plural, one {# item} other {# items}} won't surface until runtime. Linters exist for this, but most teams skip them. Inline code, by contrast, gets checked by your TypeScript compiler and your IDE the moment you type it.

Translating code directly

What if you didn't extract strings at all?

import { T } from 'gt-react';

function CheckoutSummary({ itemCount, discount }) {
  return (
    <T>
      <div>
        <h2>Order Summary</h2>
        <p>You have {itemCount} items in your cart.</p>
        {discount && <p>{discount}% discount applied!</p>}
        <p>Total (before tax):</p>
        <small>Tax calculated at checkout.</small>
      </div>
    </T>
  );
}

The <T> component in gt-react marks a block of JSX for translation. The English stays in your component. When a user views it in Spanish, the content inside <T> is replaced with its Spanish equivalent. Structure and formatting are preserved.

There's no t('checkout.summary.title'), no en.json, no per-locale JSON files to keep in sync.

Translations as build output

They're generated at deploy time. The GT CLI scans your codebase for everything inside <T> components and produces translations for every target language. The output goes into a gitignored directory, like compiled CSS or bundled JavaScript.

{
  "defaultLocale": "en",
  "locales": ["es", "fr", "ja", "de", "ko", "zh"],
  "files": {
    "gt": {
      "output": "public/_gt/[locale].json"
    }
  }
}
public/_gt/

In development, translations happen on-demand. Change a string, refresh, and see it in Japanese immediately. In production, everything is pre-generated and served from a CDN. Translation files still exist on disk, but they're output artefacts, not something you author or maintain.

What changes in practice

The difference shows up more in day-to-day work than in architecture diagrams.

A PR that adds a new section to a page reads like a PR that adds a new section to a page. The reviewer sees the actual words, not checkout.summary.discount_applied_notice. Code review no longer requires a side-by-side comparison with a JSON file.

Refactoring is less painful. Rename a component, move it, split it into pieces. Translations follow the content because there are no keys to remap. Adding a language is a one-line config change. Deleting a component means its translations quietly stop being generated, instead of leaving orphaned keys across 15 files that nobody wants to clean up.

Where i18n libraries are heading

The trend across the ecosystem is towards inline strings. Early i18n libraries like i18next and react-intl were built when machine translation wasn't viable and every string needed to be handed off to a human translator. Dictionaries made sense as an interchange format. That constraint is gone, and the developer experience cost of maintaining parallel string files is increasingly hard to justify.

next-intl added non-dictionary t() calls alongside its dictionary mode. Lingui's Compiler extracts messages at build time from inline tagged templates. Paraglide takes a different route, compiling message files into tree-shakeable functions for each locale. The approaches differ, but across all of them, content is moving closer to the component. GT takes this to its conclusion: your JSX is the source of truth, and translation is a compile step.

The trade-off

When you write content inline, you're writing in your native language. Your component structure, your sentence patterns, and your UI flow all reflect how you think in English (or whatever your source language is). A dictionary-based approach like next-intl is more language-agnostic by design, because the component never contains an actual sentence in any language, just a key that points elsewhere.

But most developers are already thinking in one language when they build a UI. The layout, the copy, and the button labels are all conceived in English first. That bias is present in the design whether the strings are inline or in a JSON file. We think the i18n framework should adapt to how you actually work. Build the app naturally, and let the framework handle translation rather than abstracting content into keys for the sake of language neutrality.

Getting started

npx gt@latest init

The setup wizard configures your project, installs dependencies, and sets up translation hot reloading for development. See the full walkthrough in the Quickstart guide.

gt-react is open source. For Next.js App Router, there's gt-next. For React Native, there's gt-react-native.