DEV Community

KunStudio
KunStudio

Posted on

A zero-conversion funnel taught me i18n is a funnel, not a page

The first time I checked my analytics, the number that stung wasn't the revenue — it was the conversion funnel. 152 visitors, 24 of them clicked "buy" and reached checkout, and exactly zero finished. Not "low conversion." Zero.

I run a small web app that does Korean Saju (사주) — a 1,000-year-old four-pillars astrology tradition — plus compatibility, face reading, tarot, and daily fortune. It's a culture/entertainment product, free to use with a paid premium tier. I had spent weeks polishing the readings. None of that mattered, because of one thing I'd ignored: language.

The bug wasn't in the code. It was in the assumption.

My analytics said 76 of my visitors were in the US, 7 in Japan, 6 in Mexico, 6 in Taiwan. A global audience. But my app defaulted to Korean for everyone, because the language was resolved like this:

const lang = localStorage.getItem('app_lang') || 'ko';
Enter fullscreen mode Exit fullscreen mode

A first-time visitor from California, with no saved preference, got a fully Korean interface. There was a language switcher with 9 languages — but it sat in a menu nobody opened. People don't hunt for an "EN" button. They bounce.

And even if they did switch the app to English, the checkout page was a separate file that had never been translated at all. So the path was: confusing Korean homepage → maybe find English → hit a 100%-Korean payment page → leave. That was my "zero."

Fix #1: detect the browser language

The whole fix for the homepage was honestly a few lines — respect navigator.language for first-time visitors, and leave returning users' explicit choice alone:

let saved = localStorage.getItem('app_lang');
if (!saved) {
  const nav = (navigator.language || 'en').toLowerCase();
  if (nav.startsWith('ko')) saved = 'ko';
  else if (nav.startsWith('zh')) {
    // Traditional vs Simplified actually matters here
    saved = /^zh-(tw|hk|hant|mo)/.test(nav) ? 'zh-hant' : 'zh-hans';
  } else {
    const map = { ja:'ja', es:'es', id:'id', vi:'vi', pt:'pt-br', hi:'hi' };
    saved = map[nav.slice(0,2)] || 'en';
  }
}
Enter fullscreen mode Exit fullscreen mode

Two things I'd underline for anyone doing this:

  • Korean stays the default-by-omission. Existing Korean users see byte-for-byte the same thing. The new branch only runs when there's no saved choice, so I couldn't accidentally break the audience I already had.
  • zh is not one language. A visitor from mainland China (zh-CN) and one from Taiwan (zh-TW) should not get the same characters. I had Traditional Chinese; I had to generate a Simplified variant (OpenCC's t2s conversion is the reliable way) and route zh-CN/zh-Hans/zh-SG to it.

Fix #2: the checkout page was its own island

This was the real lesson. My single-page app had a tidy i18n dictionary with all 9 languages. The checkout page didn't use it at all — it was hardcoded in the original language with its own little script that only checked "is this user Korean? then reorder the payment buttons."

If your funnel spans more than one document, each document needs its own localization pass. A translated app with an untranslated checkout converts like an untranslated app. I built a small language table for the checkout's static labels and swapped them by textContent (never innerHTML — that path was getting blocked by a sanitizer hook, and rightly so), gated so the Korean path mutates nothing.

The part I almost got wrong

I assumed "no revenue" meant "the payment integration is broken." Before rewriting anything, I walked the live checkout end to end with a headless browser and watched the payment window actually open. It worked fine. The buttons were never the problem. If you're debugging a zero-conversion funnel, reproduce the real user's path first — in their language, from their country if you can — before you touch payment code. I'd have wasted a day in the PG SDK otherwise.

What I'd tell past-me

  • A global product that defaults to your own language is silently throwing away most of its traffic. navigator.language is one of the highest-leverage lines of code you'll write.
  • Localization isn't a page; it's a funnel. Homepage, landing pages, and checkout each need it.
  • Verify the boring assumption (is payment even broken?) with a real session before the heroic refactor.

The app now auto-serves Korean, English, Japanese, Traditional & Simplified Chinese, Spanish, Portuguese, Indonesian, Vietnamese, and Hindi — homepage through checkout. If you're curious what 1,000-year-old Korean astrology looks like rendered for the whole world, it's at sajuapp.app. Free to try.

If you've shipped i18n on a real funnel, I'd love to hear how you handled the multi-document problem — it's the part nobody warns you about.

Top comments (0)