I run bacotto, a B2B sales-list SaaS for the Japanese market. It started Japanese-only. Last week I made the whole thing bilingual — landing page, dashboard, auth, emails, ~17 blog posts, and 4,364 programmatic SEO pages. Here's the real write-up: the architecture, and the things that broke.
The setup
- Next.js 15 (App Router), TypeScript, React 19
- ~1,565 hard-coded Japanese strings across 40+ files
- 4,364 statically-generated programmatic SEO pages (
force-static,dynamicParams = false) - Goal: Japanese stays the default at
/, English served from/en, no SEO regression
Why next-intl
I evaluated next-intl, Paraglide, and rolling my own. next-intl won because:
- App Router-native, works with Server Components without "use client" thrash
- Supports static generation — critical for the 4,364
force-staticpages - Built-in middleware for locale negotiation + hreflang helpers
Paraglide is leaner, but with only two locales its tree-shaking edge didn't matter. Rolling my own across that much SEO surface was a trap.
URL strategy: localePrefix: 'as-needed'
// src/i18n/routing.ts
export const routing = defineRouting({
locales: ["ja", "en"],
defaultLocale: "ja",
localePrefix: "as-needed",
localeDetection: false,
});
as-needed means the default locale (ja) keeps its bare URLs — /, /list/..., /blog/... — while English gets /en/.... This matters: I had ~4,364 Japanese pages already indexed. Forcing them all to /ja/... would have triggered a mass 301 chain and bled link equity.
The first thing that broke: auto-redirect would have killed my SEO
next-intl can auto-detect locale and redirect. I turned it off (localeDetection: false).
Why: Googlebot crawls from US IPs with Accept-Language: en. If / auto-redirects en-preferring clients to /en, Googlebot gets bounced off the entire Japanese tree and the Japanese SERP presence collapses. Auto-redirect on the root of a multilingual site is a footgun. I show a manual "English / 日本語" switch instead.
Restructuring into [locale]
Everything under src/app/ moved into src/app/[locale]/ — except api/, sitemap.ts, robots.ts, and route handlers, which are locale-agnostic. The [locale]/layout.tsx becomes the de-facto root layout (<html lang={locale}>).
The gotcha: setRequestLocale(locale) must be called in every statically-rendered route, or next-intl forces the whole tree dynamic. Miss it on one page and your force-static silently becomes force-dynamic.
The build error that only showed up in production
Local tsc --noEmit was clean. The Vercel build logged this 200+ times:
Error: MISSING_MESSAGE: blog.detail.readMinutes (en)
The blog detail page called t('readMinutes') from the blog.detail namespace, but the key only existed under blog.index. TypeScript doesn't validate message-key existence against the JSON by default — so this only surfaced at static-generation time, per page. Lesson: a missing-key check belongs in CI, or use next-intl's Formats/augmented types so the keys are type-checked.
hreflang, sitemap, and not duplicate-flagging yourself
Every page emits:
alternates: {
languages: {
ja: "https://bacotto.com/...",
en: "https://bacotto.com/en/...",
"x-default": "https://bacotto.com/...",
},
}
For the programmatic pages, in phase 1 the English variant rendered the same Japanese business data with an English UI shell. I deliberately did not list those /en/list/* URLs in the sitemap until they had genuinely localized content — otherwise Search Console flags them as duplicates. Only once the industry/region names and page copy were actually translated did the English programmatic URLs go into the sitemap.
IME corruption when automating content entry
Tangential but worth knowing: when scripting Japanese text into rich-text editors (ProseMirror, CodeMirror), typing character-by-character through a synthetic IME path corrupts multibyte input (リスト → リスツノ). The fix is to dispatch a real ClipboardEvent('paste') with text/html or text/plain data instead of simulating keystrokes.
Emails are part of i18n too
Easy to forget. The welcome email, subscription receipt, top-up receipt, and day-1 onboarding nudge all needed locale branching. The tricky part: the Stripe webhook and the cron job have no request cookie to read locale from — so the user's locale has to be persisted on the account record at signup (from the NEXT_LOCALE cookie / Accept-Language), and every downstream sender reads it from there.
Result
bacotto.com stays Japanese; bacotto.com/en is a fully English site — LP, dashboard, auth, 17 blog posts, 4,364 programmatic pages. Japanese SEO untouched, English surface added on top.
If you're doing a non-English-market product and thinking about going bilingual: the i18n library is the easy 20%. The 80% is SEO discipline — URL strategy, hreflang, not auto-redirecting bots, and not duplicate-flagging your own pages.
Happy to answer questions. The product itself (bacotto) generates B2B sales lists for Japan — type an industry + region, get 100 leads with phone/email/Instagram/LINE in 3 minutes.
Top comments (0)