Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions content/posts/en/i18n-01-foundation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
title: "Service Internationalization (1) - Stripping Out Hardcoded Korean"
date: "2026-05-19"
description: "Frontend internationalization, kicked off ahead of our launch in Japan. Here's how we settled on a locale model, managed our messages, and flowed the locale through the entire system."
tags: ["i18n", "nextjs", "react", "typescript"]
series: "Service Internationalization"
seriesOrder: 1
draft: false
---

## Background

Once launching in Japan landed on the roadmap, the internationalization (i18n) work we'd been putting off suddenly became urgent. The problem was that the service had grown for years on the assumption that it "only ever ran in Korean." UI copy was baked into components as string literals, dates used the Korean format, prices were fixed to won, and in some places we piped the Korean messages the server handed down straight onto the screen.

It started as "we just need to add English and Japanese, right?" but in practice, **finding and stripping out all the hardcoded Korean** was the job in itself. This post covers that first step - settling on a locale model, moving messages into a managed system, and flowing the locale through the whole system.

## First things first: how to decide the locale

The very first thing we settled was "what should we consider this user's language to be." We gave the user setting (`LANGUAGE_CODE`) four possible values.

- `ko` / `en` / `ja` - the language the user explicitly chose
- `system` - follows the device/browser language

When it's `system`, we read the system language from the browser (or the native WebView), and if it's an unsupported language, we fall back to `en`. This is how we **separated the stored value from the language actually used on screen**. The stored value is one of `system | ko | en | ja`, and for display we always compute and use a resolved `effectiveLocale` (one of ko/en/ja).

```mermaid
flowchart TB
A["Request comes in"] --> B{"LANGUAGE_CODE"}
B -->|"ko / en / ja"| C["That value is the locale"]
B -->|"system"| D["Browser / WebView<br/>system language"]
D --> E{"Supported language?"}
E -->|"ko / en / ja"| C
E -->|"Unsupported"| F["Fall back to en"]
C --> G["effectiveLocale resolved"]
F --> G
```

Because we split the stored value from the display value, a user who chose "follow the system" sees the app change along with their device language, while a user who explicitly picked a specific language keeps that choice.

## Messages are data, not code

The second thing we did was move the strings baked into the screen into a **message catalog**. We keep per-locale JSON (`ko.json` / `en.json` / `ja.json`), and components reference a key instead of a string.

```tsx
// Before
<button>예약하기</button>

// After
<button>{t('booking.submit')}</button>
```

```json
// ko.json
{ "booking": { "submit": "예약하기" } }
// ja.json
{ "booking": { "submit": "予約する" } }
```

There's one important principle here. **The JSON in git is the single source of truth** (SSOT). Translated copy is managed in JSON inside the code repository, and the delivery channel (GCS, which we'll cover in the next post) is just a transport layer that carries it around.

The catch is that once keys grow into the hundreds or thousands, **missing keys across locales** are inevitable. If a key exists only in `ko.json` but is missing from `ja.json`, Korean pops out in that spot or the raw key string is exposed. So we attached a **validation script** to CI that catches any mismatch between the key sets of the three locales.

```bash
node scripts/validate-i18n-messages.mjs
# Compares ko/en/ja for missing/extra keys and fails on any mismatch
```

## Flowing the locale through the entire system

The locale wasn't just a matter of UI copy. We had to propagate the resolved `effectiveLocale` consistently to every corner of the system.

- **`html lang`** - sets the document language to the locale (accessibility, SEO)
- **dayjs locale** - matches date/time formatting to the locale
- **`Accept-Language` header** - sent along on BFF/RPC/API calls so the server responds in the same language too
- **native WebView** - when the app opens a WebView, it passes the system language via the `system_locale` query param and the `Accept-Language` header

Flowing `Accept-Language` all the way to the server especially mattered. No matter how well you translate the copy on the frontend, if the server hands down a Korean error message, Korean creeps back onto the screen. Replacing the spots where "the lesson-history badge exposed a backend status value verbatim" with locale messages was part of the same effort.

## Wrap-up

Stage one of internationalization wasn't a flashy feature but **cleanup**.

- Separated the stored value (`system | ko | en | ja`) from the display value (`effectiveLocale`)
- Migrated strings into a git JSON message catalog and guarded against missing keys with CI
- Propagated the locale consistently across html, dayjs, Accept-Language, and the native WebView

Once we'd moved the copy into JSON, the next question remained. **Do we ship this language pack together with the app, or fetch it separately at runtime?** After all, we can't redeploy the app every time we fix a single typo. The next post covers the story of serving the language pack via bundling, GCS runtime loading, and fallback.
103 changes: 103 additions & 0 deletions content/posts/en/i18n-02-language-pack-gcs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
---
title: "Service Internationalization (2) - Serving Language Packs: Bundle, GCS Runtime Load, and Cache"
date: "2026-06-03"
description: "If you ship translations together with the app, every one-line copy fix means a redeploy. This post covers the structure that loads language packs from GCS at runtime and falls back to the bundle, plus the cache problem where copy wouldn't change even after deploying."
tags: ["i18n", "nextjs", "gcs", "typescript"]
series: "Service Internationalization"
seriesOrder: 2
draft: false
---

## The problem: bundling translations into the app means a deploy for every copy fix

In [part 1](/posts/i18n-01-foundation) we moved the on-screen copy into per-locale JSON. So when do we load this JSON? The easiest option is to **include it in the build bundle**. But then every typo, every one-line copy fix, forces us to rebuild and redeploy the app. And translations are one of the things that change most often while a service is running.

So we set the goal like this: **change copy without a deploy, but no matter what happens, never let the screen break.**

## The choice: runtime load + bundle fallback

Bundling and runtime loading come with a trade-off.

- **Bundle** - Fast and certain. It ships with the app, so it's always present. The catch is that changing it requires a redeploy.
- **Runtime load** - Copy can be updated any time. The catch is that if the load fails, the screen goes blank.

We decided to use **both**. Normally we fetch the latest pack at runtime, but if that fails, we fall back to the bundle catalog included in the build. It's a structure that gives us freshness and stability at the same time.

## Publishing the git JSON to GCS as versioned packs

As mentioned before, **the JSON in git is the single source of truth**. A separate publish script carries this JSON over to GCS. It uploads a timestamped versioned pack together with a `metadata.json` that points to which pack is the latest.

```js
// scripts/publish-i18n-gcs.mjs (gist)
for (const locale of ['ko', 'en', 'ja']) {
const body = await readFile(`.../messages/${locale}.json`, 'utf-8')
const objectPath = `intl/${locale}_${timestamp}.json` // versioned pack
await bucket.file(objectPath).save(body, { contentType, resumable: false })
files[locale] = { path: `/${objectPath}`, timestamp, size: body.length }
}

// pointer to the latest pack
await bucket.file('intl/metadata.json')
.save(JSON.stringify({ lastUpdated: timestamp, files }))
```

Because it's a timestamped versioned pack + pointer (`metadata.json`) structure, a rollback is just reverting the metadata. For authentication we use Workload Identity/ADC instead of a key file.

## The runtime loader: read metadata, and on failure fall back to the bundle

The load happens on the server (an App Router server component). It reads `metadata.json`, fetches each locale's pack, and **on a per-locale failure, falls back to the bundle for that locale only**. A full failure, such as an unconfigured bucket or a failure of the metadata itself, falls back wholesale to the bundle catalog.

```ts
const loadFromGcs = async () => {
if (!BUCKET_NAME) return bundledMessages // bucket not configured → bundle

const bucket = new Storage().bucket(BUCKET_NAME)
const metadata = await downloadJson(bucket, 'intl/metadata.json')

const entries = await Promise.all(
SUPPORTED_LOCALES.map(async (locale) => {
try {
const path = metadata.files?.[locale]?.path
if (!path) return [locale, bundledMessages[locale]]
return [locale, await downloadJson(bucket, path)]
} catch {
return [locale, bundledMessages[locale]] // fall back to bundle for this locale only
}
}),
)
return Object.fromEntries(entries)
}
```

Thanks to this structure, even if only the ja pack is uploaded incorrectly, only ja drops to the bundle while ko and en keep using the latest pack as-is. The key point is: "an incident in one locale doesn't bring the whole thing down."

## We deployed, but the old copy still won't change

This is where a real headache showed up. Hitting GCS on every request is slow and expensive, so we wrapped it in `unstable_cache`.

```ts
export const loadLanguagePacks = () =>
unstable_cache(
async () => {
try { return await loadFromGcs() } catch { return bundledMessages }
},
['i18n-language-packs'],
{ tags: ['i18n-language-packs'], revalidate: 600 }, // ← this TTL is the key
)()
```

At first we set `revalidate: false`. The thinking was, "we can just revalidate language packs by tag when we publish." But **even after deploying, the old copy kept showing up.**

The cause was the cache store. Our deployment uses `cache-handler` to **persist the Next cache in Redis**. `revalidate: false` effectively means "cache indefinitely," so even after redeploying the app, the old catalog still sitting in Redis kept being served. Even though the build changed, the cache key was the same, so it was never invalidated.

The fix was simple. We **gave it a TTL** (`revalidate: 600`). We revalidate immediately by tag on publish, but even if that's missed, it naturally rolls over to the new pack within at most 10 minutes. In effect, we added one more safety net — an "expiry" — to the persistent cache.

## Wrap-up

What we learned from serving language packs was, in the end, **stacking layers of fallback**.

- git JSON (SSOT) → publish as GCS versioned packs + a metadata pointer
- Runtime load, but with bundle fallback at both the per-locale and full-failure level
- Put a TTL safety net on the persistent cache to prevent the "won't change even after deploying" incident

Now copy can be changed without a deploy, and no matter what happens, the screen renders. What's left is **how to safely turn this internationalization on for real users**. In the next part, I'll cover the story of rolling out i18n gradually with zero downtime using a GrowthBook kill switch.
89 changes: 89 additions & 0 deletions content/posts/en/i18n-03-rollout-killswitch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
title: "Service Internationalization (3) - Zero-Downtime Rollout with a GrowthBook Kill Switch"
date: "2026-06-17"
description: "Even with all translations ready, flipping the switch for everyone at once is risky. The story of funneling locale decisions into a single choke point and rolling out i18n with zero downtime, gradually, via a GrowthBook kill switch."
tags: ["i18n", "nextjs", "growthbook", "feature-flag"]
series: "Service Internationalization"
seriesOrder: 3
draft: false
---

## Background

In parts 1 and 2, I moved copy into a message catalog and got the language packs served from GCS. So can I just turn it on for everyone now? No. Opening up i18n to everyone in one shot is risky.

- Some screen with a missing translation might still be lurking somewhere, so Korean could pop out unexpectedly
- The same sentence differs in length across Japanese and English, so layouts can break
- Awkward or wrong translations might surface only later

So the goal was singular. **Turn it on gradually, but if something goes wrong, roll it back instantly without a deploy.** That mechanism is the GrowthBook kill switch.

## Funneling locale decisions into one place: a single choke point

For a kill switch to work properly, the point that decides "what is this request's locale" must be **exactly one place**. If everyone decides the locale on their own all over the codebase, then even with the kill switch off, something somewhere still renders in Japanese.

So I funneled the decision into a single `resolveEffectiveLocale`, and injected the flag (`i18nEnabled`) into it.

```ts
export const resolveEffectiveLocale = ({
languageCode, systemLocale, acceptLanguage, i18nEnabled = true,
}: ResolveArgs): Locale => {
// i18n kill switch - if off, ko no matter what. The single choke point for locale decisions.
if (!i18nEnabled) return 'ko'

const code = normalizeLanguageCode(languageCode)
if (code !== 'system') return code

return normalizeLocale(systemLocale)
?? resolveLocaleFromAcceptLanguage(acceptLanguage)
?? FALLBACK_LOCALE
}
```

When the flag is off, whether the user's setting is `en` or `ja`, whatever the system language is, it **falls back to Korean unconditionally.** It's as if I put a switch on the one gate every locale must pass through. The flag value is managed by a single `i18n_enabled` in GrowthBook.

## Making the server and client reach the same decision

Because it's the Next.js App Router, the locale is used on both the server (the layout) and the client (the provider). If the two pick different locales, hydration breaks. So **both feed the same flag into the same `resolveEffectiveLocale`.**

```tsx
// Server: the layout reads the flag and passes it down
const i18nEnabled = flagResult.values[FEATURE_FLAG_KEYS.I18N_ENABLED]?.enabled ?? false
// ...
<I18nProvider i18nEnabled={i18nEnabled} /* ... */>
```

The server reads the flag to decide `html lang` and the first render, and the client provider computes the same `effectiveLocale` from the same flag. When the flag is off, both server and client are `ko`, so the render results don't diverge.

## True zero-downtime means being able to roll back

The real worth of a kill switch is that you can **roll it back instantly without a deploy.**

- **Gradual rollout** - In GrowthBook, turn it on first for only a certain percentage or a certain user group
- **Instant cutoff** - If a translation incident is found, the moment you flip the flag off, everyone safely reverts to `ko`. No need to wait for a rollback deploy

"If you can't roll a feature back, you can't turn it on" was the principle, and the kill switch upheld that principle.

## Pin tests to ko

If the locale wavers from test to test, snapshots and E2E become flaky. So I pinned the locale in the test environment to `ko` by default. And I **nailed down the invariant itself in a unit test** - "if the flag is off, always ko."

```ts
test('forces ko when i18n is disabled, regardless of language/system', () => {
expect(resolveEffectiveLocale({ languageCode: 'en', systemLocale: 'ja-JP', i18nEnabled: false })).toBe('ko')
expect(resolveEffectiveLocale({ languageCode: 'ja', i18nEnabled: false })).toBe('ko')
expect(resolveEffectiveLocale({ languageCode: 'en', i18nEnabled: true })).toBe('en')
})
```

As long as this test stays green, no matter who touches the locale logic, the safety net of "kill switch off = everyone ko" won't break.

## Wrap-up

The core of a zero-downtime rollout was ultimately **a structure you can roll back.**

- Funnel locale decisions into the `resolveEffectiveLocale` single choke point
- Control server and client at once with a single GrowthBook `i18n_enabled`
- Pin the "if off, always ko" invariant with a test

That's the frontend story so far. But no matter how well you translate on the frontend, if the server hands down a Korean response or a Korean document, Korean gets mixed back into the screen. In the final part, I'll cover how the backend stored the user's language and time zone and carried the locale all the way through to responses and documents.
Loading
Loading