Internationalizing BookBit: Adding i18n to a React Native App

10 min read
bookbitreact-nativei18nexpoindie-hacking

Why i18n Matters for Indie Hackers

Most indie hackers ship in English and call it a day. I almost did the same with BookBit, my reading tracker app. But then I looked at the numbers: Brazil has over 200 million people, a growing smartphone market, and a reading culture that's quietly massive. The country hosts one of the largest book fairs in the world. And I happen to speak Portuguese natively.

Leaving that market on the table felt like leaving money on the table.

BookBit tracks reading sessions with a timer, manages your library with barcode scanning, sets goals, and shows progress stats. It's built with Expo v54, Expo Router v6, Tamagui for the UI, Supabase on the backend, and Zustand for state management. Not a trivial app -- and that's exactly why I wanted to get i18n right early, before the codebase grew any further.

The whole implementation took just 2 focused commits. Here's how.

Choosing the Stack

For React Native i18n, the ecosystem has converged on a clear winner: i18next with react-i18next. Add expo-localization for device locale detection and you have everything you need.

npx expo install i18next react-i18next expo-localization

Why i18next over alternatives like react-intl or LinguiJS? A few reasons:

  • It works identically in React Native and web, so if I ever build a BookBit web companion, the translations port directly
  • The namespace system keeps large translation files manageable
  • Pluralization and interpolation are built-in and battle-tested
  • The TypeScript support is excellent with custom type declarations

Organizing 357+ Translation Keys

The first decision was how to structure translations. I went with 13 namespaces that map roughly to features:

locales/
  en/
    common.json
    auth.json
    library.json
    book.json
    session.json
    goals.json
    stats.json
    settings.json
    search.json
    scanner.json
    tabs.json
    errors.json
    onboarding.json
  pt/
    common.json
    auth.json
    ...same structure

Each namespace stays small and focused. The common namespace holds shared strings like button labels and generic messages. Feature namespaces hold everything specific to that screen or flow.

Here's what a typical namespace looks like:

{
  "title": "Library",
  "empty": "Your library is empty",
  "addBook": "Add a book",
  "searchPlaceholder": "Search your books...",
  "sortBy": "Sort by",
  "sortOptions": {
    "recent": "Recently added",
    "title": "Title",
    "author": "Author",
    "progress": "Progress"
  },
  "bookCount": "{{count}} book",
  "bookCount_plural": "{{count}} books"
}

Notice the pluralization at the bottom -- i18next handles this automatically based on the count value. In Portuguese, the plural form is different: "{{count}} livro" vs "{{count}} livros". Each language defines its own plural rules.

The i18next Configuration

The config needed a few React Native-specific tweaks that aren't obvious from the docs:

import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import * as Localization from "expo-localization";
import AsyncStorage from "@react-native-async-storage/async-storage";

// Import all namespaces
import enCommon from "./locales/en/common.json";
import enAuth from "./locales/en/auth.json";
import enLibrary from "./locales/en/library.json";
// ... all other namespaces
import ptCommon from "./locales/pt/common.json";
import ptAuth from "./locales/pt/auth.json";
import ptLibrary from "./locales/pt/library.json";
// ... all other pt namespaces

const LANGUAGE_STORAGE_KEY = "@bookbit/language";

async function getInitialLanguage(): Promise<string> {
  // 1. Check stored preference
  const stored = await AsyncStorage.getItem(LANGUAGE_STORAGE_KEY);
  if (stored && ["en", "pt"].includes(stored)) return stored;

  // 2. Check device locale
  const deviceLocale = Localization.getLocales()[0]?.languageCode;
  if (deviceLocale && ["en", "pt"].includes(deviceLocale)) return deviceLocale;

  // 3. Fallback
  return "en";
}

export async function initI18n() {
  const lng = await getInitialLanguage();

  await i18n.use(initReactI18next).init({
    compatibilityJSON: "v3",
    lng,
    fallbackLng: "en",
    ns: [
      "common", "auth", "library", "book", "session",
      "goals", "stats", "settings", "search", "scanner", "tabs",
      "errors", "onboarding",
    ],
    defaultNS: "common",
    resources: {
      en: {
        common: enCommon,
        auth: enAuth,
        library: enLibrary,
        // ...
      },
      pt: {
        common: ptCommon,
        auth: ptAuth,
        library: ptLibrary,
        // ...
      },
    },
    interpolation: {
      escapeValue: false,
    },
    react: {
      useSuspense: false,
    },
  });

  return i18n;
}

Two critical settings here:

  1. compatibilityJSON: "v3" -- This is required for Expo. Without it, i18next uses v4 JSON format which has issues with Hermes, React Native's JS engine. If you skip this, pluralization silently breaks.

  2. useSuspense: false -- React Native doesn't support Suspense the same way web React does. Setting this to false prevents the entire app from crashing when translations load asynchronously.

Type Safety for Translation Keys

With 357+ keys, typos are inevitable without type checking. I added custom type declarations so TypeScript autocompletes every translation key:

// types/i18next.d.ts
import "i18next";
import common from "../locales/en/common.json";
import auth from "../locales/en/auth.json";
import library from "../locales/en/library.json";
// ...all namespaces

declare module "i18next" {
  interface CustomTypeOptions {
    defaultNS: "common";
    resources: {
      common: typeof common;
      auth: typeof auth;
      library: typeof library;
      book: typeof book;
      session: typeof session;
      goals: typeof goals;
      stats: typeof stats;
      settings: typeof settings;
      search: typeof search;
      scanner: typeof scanner;
      tabs: typeof tabs;
      errors: typeof errors;
      onboarding: typeof onboarding;
    };
  }
}

Now when I write t("library:searchPlaceholder"), TypeScript validates both the namespace and the key. If I rename a key in the JSON, every component that references it shows a red squiggly immediately. This saved me at least a dozen runtime errors during the initial migration.

3-Tier Persistence Strategy

Language preference needed to survive app restarts and sync across devices. I built a 3-tier system:

Tier 1: i18next in-memory -- The active language lives in i18next's runtime state. Instant access, zero latency. This is what useTranslation() reads from.

Tier 2: AsyncStorage local -- When the user changes their language, it writes to AsyncStorage immediately. On next app launch, the init function reads this before anything renders. No flash of the wrong language.

Tier 3: Supabase cloud -- A preferred_language column on the user's profile syncs to the server. If someone logs into a new device, their preference follows them.

async function changeLanguage(lang: string) {
  // Tier 1: Update runtime
  await i18n.changeLanguage(lang);

  // Tier 2: Persist locally
  await AsyncStorage.setItem(LANGUAGE_STORAGE_KEY, lang);

  // Tier 3: Sync to cloud (fire and forget)
  supabase
    .from("profiles")
    .update({ preferred_language: lang })
    .eq("id", userId)
    .then(() => {})
    .catch(console.warn);
}

The cloud sync is fire-and-forget. If it fails, the local preference is already saved. Next time they open the app, it uses the local value. The cloud sync catches up eventually.

The detection priority on app launch mirrors this in reverse: check Supabase profile first (if logged in), then AsyncStorage, then device locale via expo-localization, then fallback to English.

Migrating 32 Components

The actual migration was methodical but straightforward. Every component with user-facing text needed the useTranslation hook:

// Before
function LibraryHeader({ bookCount }: { bookCount: number }) {
  return (
    <View>
      <Text>My Library</Text>
      <Text>{bookCount} books</Text>
    </View>
  );
}

// After
function LibraryHeader({ bookCount }: { bookCount: number }) {
  const { t } = useTranslation("library");
  return (
    <View>
      <Text>{t("title")}</Text>
      <Text>{t("bookCount", { count: bookCount })}</Text>
    </View>
  );
}

Across 32 components, I established a pattern:

  1. Import useTranslation from react-i18next
  2. Destructure t with the appropriate namespace
  3. Replace every hardcoded string with a t() call
  4. Add interpolation variables where needed

Variable interpolation was particularly useful for dynamic strings:

// "Read {{pages}} pages {{when}}"
t("session:readPages", { pages: 42, when: t("common:today") })
// EN: "Read 42 pages today"
// PT: "Leu 42 paginas hoje"

The Tricky Parts

Not everything was a straightforward string replacement. Three areas caused friction.

Alert.alert Dialogs

React Native's Alert.alert takes plain strings, not React components. You can't use hooks inside it. The solution is to call t() before passing strings to the alert:

function handleDeleteBook() {
  const { t } = useTranslation("book");

  Alert.alert(
    t("deleteConfirmTitle"),
    t("deleteConfirmMessage", { title: book.title }),
    [
      { text: t("common:cancel"), style: "cancel" },
      { text: t("common:delete"), style: "destructive", onPress: confirmDelete },
    ]
  );
}

This works because t() returns a plain string. But you have to make sure the hook is called at the component level, not inside the alert callback. I caught this mistake twice during the migration.

Expo Router lets you set screen options statically or dynamically. For translated headers, dynamic is the way to go:

export default function LibraryScreen() {
  const { t } = useTranslation("tabs");

  return (
    <>
      <Stack.Screen options={{ title: t("library") }} />
      {/* screen content */}
    </>
  );
}

The header re-renders when the language changes because useTranslation triggers a re-render on language switch. No extra work needed.

Form Validation Messages

Validation messages in forms need to be translated too, but they're often defined as static schemas. I moved validation message generation inside components where the t function is available:

function useBookFormSchema() {
  const { t } = useTranslation("book");

  return z.object({
    title: z.string().min(1, t("validation.titleRequired")),
    author: z.string().min(1, t("validation.authorRequired")),
    totalPages: z.number().min(1, t("validation.pagesRequired")),
  });
}

Creating the schema inside a hook means it rebuilds when the language changes. Slightly less efficient than a static schema, but validation schemas are cheap to create and the alternative (stale messages in the wrong language) is worse.

The Language Picker

The settings screen got a new language picker. I wanted it to feel native and informative:

function LanguagePicker() {
  const { i18n } = useTranslation();

  const languages = [
    { code: "en", native: "English", english: "English" },
    { code: "pt", native: "Portugues", english: "Portuguese" },
  ];

  return (
    <View>
      {languages.map((lang) => (
        <Pressable
          key={lang.code}
          onPress={() => changeLanguage(lang.code)}
        >
          <View style={styles.row}>
            <View>
              <Text style={styles.nativeName}>{lang.native}</Text>
              <Text style={styles.englishName}>{lang.english}</Text>
            </View>
            {i18n.language === lang.code && (
              <CheckIcon />
            )}
          </View>
        </Pressable>
      ))}
    </View>
  );
}

Showing both the native name and the English name is a small touch that helps. If someone accidentally switches to a language they don't read, they can still identify the one they want by the English label.

What I Learned

Start i18n early. I added it with 32 components and 357 keys. If I'd waited until 100 components, the migration would have been painful. Two focused commits got it done at this size. At three times the size, it would have been a multi-day slog.

Namespaces are worth the overhead. It's tempting to dump everything into one big translations file. Don't. When you're working on the scanner feature, you only need to open scanner.json. When a translator works on the app, they can tackle one namespace at a time.

Test with the non-default language. I spent an entire day using BookBit in Portuguese after the migration. Found three missing translations and one interpolation bug that only appeared in Portuguese because the sentence structure put the variable in a different position.

The compatibilityJSON: "v3" gotcha is real. I lost an hour to pluralization silently failing before finding this setting. If you're using i18next with Expo and Hermes, save yourself the debugging and add it from the start.

What's Next

Two languages is just the start. Spanish is the obvious next addition -- it shares enough structure with Portuguese that translation would be fast. French and German are on the radar for European markets.

RTL support (Arabic, Hebrew) is a bigger undertaking. React Native has RTL support built in with I18nManager.forceRTL(), but Tamagui components need testing to make sure layouts flip correctly. That's a project for another day.

For now, BookBit speaks English and Portuguese. That covers my two primary markets, and the foundation is solid for whatever comes next. The 357 keys, 13 namespaces, and 3-tier persistence will scale to 10 languages without changing the architecture. That's the whole point of getting it right early.