DEV Community

KunStudio
KunStudio

Posted on • Originally published at sajuapp.app

How I shipped a 9-language Korean Saju AI as a solo founder — and what I learned about i18n at scale

How I shipped a 9-language Korean Saju AI as a solo founder — and what I learned about i18n at scale

I'm Hong Deokhoon, a solo founder from Gyeongju, Korea. Over the last few months I shipped Cheonmyeongdang (sajuapp.app) — a Korean Saju (사주, the 1,000-year-old four-pillars-of-destiny system) AI reader that speaks 9 languages: Korean, English, Japanese, Chinese (Simplified), Spanish, French, German, Portuguese, and Vietnamese.

No team. No funding. One brain, one keyboard, four products in parallel.

Today I want to talk about the part that ate the most of my time: internationalization at scale on Next.js when the domain itself is culturally encoded.

If you've ever tried to translate a product where the core concept (Saju) doesn't even exist in the target reader's vocabulary, you'll know this is not a react-i18next problem. It's an epistemology problem disguised as a string-replacement problem.

Here's what I learned.


1. The stack (boring on purpose)

  • Next.js 14 App Router with subpath routing (/en, /ja, /zh, ...)
  • Vercel Hobby (yes, still — the 12 serverless functions limit is real and I'll come back to it)
  • Supabase for auth + Postgres
  • PayPal Subscriptions for global billing (Stripe is unavailable to Korean sole proprietors — a fact that cost me two weeks of denial)
  • Anthropic Claude for the Saju interpretation engine
  • GPT-4o-mini as a translation second-pass for terminology consistency

The boring stack is the point. When you're solo and shipping 4 products, every "clever" choice is a future bug you'll fix at 2 AM.


2. Subpath routing beat subdomain routing — but only barely

I started with subdomains (en.sajuapp.app, ja.sajuapp.app). Within a week I rolled back.

Why subpath won:

  • One Vercel project, one deployment. Subdomains meant either N projects or N rewrites — both fragile.
  • Canonical SEO is simpler. hreflang on a single domain with /locale/ paths is the pattern Google recommends most loudly.
  • Cookie consent + auth state survives language switches. Subdomains share cookies only if you scope them to the apex — which works until it doesn't (Safari ITP, mobile webviews, etc.).

Why subdomain almost won:

  • Per-locale CDN routing is easier when you have real regional Vercel/CF edges. I didn't need this. You probably don't either until you have paying customers in 3+ regions.

Verdict: if you have <$10K MRR, use subpaths. If you ever cross $10K MRR and have geo-latency complaints, then revisit.


3. The translation pipeline that actually worked

My first attempt was the obvious one: one big messages/{locale}.json per language, hand-curated, with a Crowdin-style workflow.

That collapsed within a week because Saju terminology has no stable target-language equivalents. "갑목(甲木)" is not "Wood Yang." It's a concept. The English reader needs context, the Japanese reader needs a different context (they have 四柱推命 already), and the Vietnamese reader is somewhere in between.

What worked instead:

locales/
  ko/
    common.json          ← UI strings
    saju-glossary.json   ← canonical Korean term + definition
  en/
    common.json
    saju-glossary.json   ← AI-translated, human-reviewed
  ...
Enter fullscreen mode Exit fullscreen mode

Two-stage pipeline:

  1. Claude pass: translate common.json with the glossary injected as system context. This keeps "갑목" rendering consistently across 600+ UI strings.
  2. GPT-4o-mini pass: a second model reviews the first pass and flags terminology drift. Whichever disagrees with the glossary loses.

Disagreement rate after the second pass: ~3%. I review those by hand. The rest ships.

Cost: about $0.40 per language per full retranslation. I've done 14 full retranslations to date.


4. The hreflang trap

I shipped 9 locales. Google indexed 2.

The cause was a classic hreflang misconfiguration: I had <link rel="alternate" hreflang="en" href="..."/> tags that pointed to the current page in each locale, but the x-default was missing, and the Japanese page declared hreflang="ja-JP" while pointing at a URL that 301-redirected to /ja/ (no country code).

After fixing this, indexing across the other 7 locales started within 5 days.

Rule I now follow: every locale page must include x-default pointing at the Korean root, must list every other locale, and the hreflang code must be the BCP 47 tag that matches the actual URL path. No region codes unless the URL has region codes.


5. The Vercel 12-function limit

Hobby tier on Vercel allows 12 serverless functions per deployment. With 9 locales, it's tempting to make /api/[locale]/saju.ts and let Next.js do its thing. Don't.

What I do instead:

  • One single /api/saju.ts that reads req.headers['accept-language'] and a query param.
  • The locale-aware response is built inside one function.
  • Total functions: 8 (saju, auth-callback, paypal-webhook, paypal-subscribe, paypal-cancel, contact, og-image, healthcheck).

I crossed the 12-function limit twice in three months. Each time the build failed with exceeded_serverless_functions_per_deployment. Each time I lost half a day refactoring under pressure.

Lesson: design for 12 from day one. Use mode: 'x' query params and route inside one handler instead of splitting endpoints.


6. What broke that I didn't expect

  • Template literals containing </script> tags blew up my landing page. The HTML parser closed my outer script when it hit the inner </script>. The page rendered raw code to users for 6 hours before I caught it. I now run node --check on every generated HTML build artifact.
  • Vercel vercel --prod did not always update the production alias. I now manually run vercel alias set + curl ?nocache=1 after every deploy. WebFetch's 15-minute cache once made me think a fix had landed when it hadn't.
  • Korean Saju depends on solar time + birthplace longitude. A user born in Seoul at 12:00 PM is not at "Saju noon" — they're at ~11:32 solar time. Skipping this calculation produces wrong charts. Free competitors skip it. Don't.

7. What I'd do differently

  1. Ship 2 languages, not 9. Korean + English would have been enough to validate the wedge. The other 7 locales were technical pride disguised as strategy.
  2. Charge from day one. I gave away too much for free for too long thinking I needed "traction." I needed customers.
  3. Pick the boring stack faster. I wasted a week on tRPC + Drizzle + a custom i18n loader before reverting to Next.js + next-intl + plain fetch.

8. The honest numbers

  • Live since: April 2026
  • Languages shipped: 9
  • Free users: ~1,200
  • Paying customers: still working on the first one
  • MRR: $0

I'm shipping this post the day before Product Hunt launch. Whether it works or not, the i18n architecture above is the part I'm proudest of, because it scales when revenue does.

If you're a solo founder considering multi-locale from day one: do less, ship sooner, charge earlier. The stack above will be here when you actually need it.

— Hong Deokhoon (@kunstudiokr)
sajuapp.app · korlens.app

Top comments (0)