DEV Community

j209509
j209509

Posted on

Adding i18n to a 14,000-page Next.js site with next-intl — what actually broke

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-static pages
  • 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,
});
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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/...",
  },
}
Enter fullscreen mode Exit fullscreen mode

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)