Migrating an Expo app from Japanese to multi-language support in stages

작성자

카테고리:

← 피드로
DEV Community · hiro@ · 2026-08-03 개발(SW)

hiro@

This article is an English translation of the original Japanese article.

I migrated an Expo app from Japanese only to Japanese, English, and Simplified Chinese. Instead of translating everything at once, I divided the work into three stages: foundation, core flows, and remaining screens.

The harder part was not gathering translations, but deciding device language versus manual setting priority, date formatting, and handling untranslated screens.

Defined language setting responsibility first

I used i18next and react-i18next. For device language detection, I used Hermes Intl.

export const SUPPORTED_LANGUAGES = ["ja", "en", "zh-Hans"] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
export type LanguagePreference = SupportedLanguage | "system";

export function detectDeviceLanguage(): SupportedLanguage {
  const locale = Intl.DateTimeFormat().resolvedOptions().locale ?? "";
  const lang = locale.toLowerCase().split(/[-_]/)[0];
  if (lang === "zh") return "zh-Hans";
  return lang === "ja" || lang === "en" ? lang : "ja";
}

Enter fullscreen mode Exit fullscreen mode

The setting screen stores the chosen value in SecureStore. If the saved value is system, the device setting takes priority; if it is a specific language, the manual setting wins.

export async function setLanguagePreference(pref: LanguagePreference) {
  await SecureStore.setItemAsync(LANGUAGE_PREF_KEY, pref);
  await i18n.changeLanguage(pref === "system" ? detectDeviceLanguage() : pref);
}

Enter fullscreen mode Exit fullscreen mode

Initial render initializes synchronously, then reads the saved setting later. I chose not to block the launch screen just to load the setting.

Stage 1: common UI only

First, I targeted only the bottom navigation, dashboard, and language picker in the settings screen.

const { t } = useTranslation();

<Text>{t("dashboard.title")}</Text>

Enter fullscreen mode Exit fullscreen mode

Here I found a bug. The bottom navigation was using display labels for active detection, so translation broke the logic. I added a fixed key separate from the label.

type TabItem = {
  key: "home" | "schedules" | "notifications" | "settings";
  label: string;
  href: Href;
};

Enter fullscreen mode Exit fullscreen mode

Using labels as identifiers is hard to notice until translation, but multilingual support surfaces this design issue.

Stage 2: attendance core flow

Next, I migrated the schedule list, schedule detail, and attendance submission. At this stage, the goal was a state where a user could complete daily operations in one language.

I split translation keys by screen.

{
  "scheduleDetail": {
    "attending": "参加",
    "absent": "不参加",
    "maybe": "未定",
    "waitlisted": "キャンセル待ち"
  }
}

Enter fullscreen mode Exit fullscreen mode

Status values maintain shared English identifiers with the API, translating only for display. Since the DB does not store Japanese labels, adding languages requires no data migration.

Stage 3: dates and remaining screens

Finally, I replaced admin screens, notifications, inquiry forms, empty states, and error messages. At the same time, I moved date display to Intl.DateTimeFormat.

function formatDate(date: Date, language: SupportedLanguage) {
  const locale = language === "zh-Hans" ? "zh-CN" : `${language}-${language === "ja" ? "JP" : "US"}`;
  return new Intl.DateTimeFormat(locale, {
    year: "numeric",
    month: "long",
    day: "numeric",
    weekday: "short",
  }).format(date);
}

Enter fullscreen mode Exit fullscreen mode

When reviewing only translation JSON, it is easy to miss weekdays, date/time formatting, and API-derived errors. I split the checklist per screen into “fixed strings,” “dates,” “server errors,” and “empty states.”

Web and mobile differ in language entry points

On the web, you can use Accept-Language or locale in the URL. Considering shared URLs, including locale in the URL like /en/... has the advantage of showing the same language to the recipient.

Mobile, however, does not have a URL for each launch. Using device settings as the initial value and prioritizing in-app manual settings felt more natural. Even if web and mobile share translation data, the logic that decides the language does not need to be the same.

During staged migration, Japanese mixes in. Still, completing the main flow in sections felt easier to manage than placing keys across all screens and leaving verification incomplete.

References

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다