DEV Community

Genglin Zheng
Genglin Zheng

Posted on

How I Built a Scalable i18n System in Nuxt 3 — Rolling Out New Languages One Page at a Time

I run BulkPicTools — a browser-based image tools site with 38 landing pages, all processing happening locally with no file uploads. English and Chinese were live. Then came the decision to add Japanese.

The naive approach: configure ja in nuxt-i18n, dump machine-translated JSON files, deploy. Done in an afternoon.

The problem: half the tools weren't translated yet. And nuxt-i18n doesn't know that. It'll happily generate hreflang tags pointing to /ja/image-compressor even if that page returns a 404 — or worse, silently falls back to English content while telling Google it's Japanese. That's the kind of thing that quietly tanks your search rankings.

So I built something different. A rollout system where each tool declares its own language support, and everything else — hreflang, content fallback, SEO metadata — reacts to that declaration automatically.

Here's how it works.

The Core Idea: Let the Data Decide

Every tool on BulkPicTools is defined by a JSON file. It already had en and zh keys:

{
  "slug": "image-compressor",
  "category": "compress",
  "icon": "lucide:file-zip",
  "en": {
    "name": "Image Compressor",
    "meta": { "title": "...", "description": "..." },
    "hero": { "scene": "Drag in a photo, get back a smaller one." }
  },
  "zh": {
    "name": "图片压缩",
    "meta": { "title": "...", "description": "..." },
    "hero": { "scene": "拖入图片,输出更小的文件。" }
  }
}
Enter fullscreen mode Exit fullscreen mode

When the Japanese translation is ready, you add a ja key. When it's not, you don't. That's the entire "is this language ready?" signal. No config file to maintain separately. No deployment flag to flip. The data IS the feature flag.

Setting Up the Language Registry

One file acts as the single source of truth for which languages the site supports:

// nuxt.config.ts
i18n: {
  locales: [
    { code: 'en', language: 'en',    file: 'en.json' },
    { code: 'zh', language: 'zh-CN', file: 'zh.json' },
    { code: 'ja', language: 'ja',    file: 'ja.json' },
    // Adding Korean later? Just drop this in:
    // { code: 'ko', language: 'ko', file: 'ko.json' },
  ],
  defaultLocale: 'en',
  strategy: 'prefix_except_default',
}
Enter fullscreen mode Exit fullscreen mode

That's the only place a new language gets registered. Everything downstream reads from useI18n().locales at runtime — nothing is hardcoded in utility functions.

ja vs jp — This trips people up. ja is the ISO 639-1 language code for Japanese. jp is the ISO 3166-1 country code for Japan. hreflang wants the language. Always ja, never jp. Same pattern: Chinese is zh, not cn.


The hreflang Problem

By default, nuxt-i18n's useLocaleHead() generates alternate links for every configured locale. Once you add ja to your config, it starts outputting:

<link rel="alternate" hreflang="ja" href="https://bulkpictools.com/ja/image-compressor" />
Enter fullscreen mode Exit fullscreen mode

...on every page, whether or not that Japanese page actually exists.

Google's documentation on hreflang is pretty clear: if you declare an alternate URL, it should return the actual localized content. Pointing it at a page that either 404s or just shows English is worse than not having the tag at all.

The fix is a filter function that sits between useLocaleHead() and your <head>:

// utils/hreflang.ts

export interface HreflangLink {
  rel: 'alternate'
  hreflang: string
  href: string
}

/**
 * Filters the hreflang links generated by nuxt-i18n down to only
 * the locales that this specific page actually supports.
 *
 * @param i18nLinks     Raw links from useLocaleHead()
 * @param supportedCodes  Locale codes this page has content for, e.g. ['en', 'zh']
 * @param allLocales    The full locales array from useI18n()
 */
export function filterHreflangLinks(
  i18nLinks: any[],
  supportedCodes: string[],
  allLocales: { code: string; language?: string; iso?: string }[],
): HreflangLink[] {
  // Build a map from locale code → hreflang value
  // e.g. { en: 'en', zh: 'zh-CN', ja: 'ja' }
  const codeToHreflang = Object.fromEntries(
    allLocales.map((l) => [l.code, l.language ?? l.iso ?? l.code])
  )

  const supportedHreflangs = supportedCodes.map((code) => codeToHreflang[code])

  return (i18nLinks ?? []).filter((link) =>
    link.hreflang === 'x-default' ||
    supportedHreflangs.some((h) => link.hreflang?.startsWith(h))
  )
}
Enter fullscreen mode Exit fullscreen mode

The function doesn't know or care which languages exist. It just takes what nuxt-i18n generates and filters it against the list you pass in. When you add Korean later, nothing here needs to change.

Wiring It Into the Tool Page

Each tool landing page reads its own JSON, checks which locale keys exist, and uses that list for both hreflang and content:

<script setup lang="ts">
import { filterHreflangLinks } from '~/utils/hreflang'

// tool is the raw JSON for this page
const { locale, locales } = useI18n()
const i18nHead = useLocaleHead({ addSeoAttributes: true })

// The supported locales come directly from the JSON structure.
// If 'ja' key exists in the JSON → Japanese is supported.
// If not → it won't appear in hreflang, and content falls back to 'en'.
const supportedLocales = Object.keys(tool).filter((k) =>
  typeof tool[k] === 'object' && tool[k]?.meta
)

// Fallback chain: current locale → English → empty object
const activeLocale = computed(() =>
  supportedLocales.includes(locale.value) ? locale.value : 'en'
)

const localeMeta = computed(() => tool[activeLocale.value])

// SEO — uses the fallback locale, so Japanese visitors get English
// meta rather than undefined crashing the page
useSeoMeta({
  title: "localeMeta.value.meta.title,"
  description: "localeMeta.value.meta.description,"
  ogTitle: localeMeta.value.meta.title,
  ogDescription: localeMeta.value.meta.description,
})

// hreflang — only outputs languages that actually have content
definePageMeta({ customHreflang: true })

useHead({
  link: computed(() =>
    filterHreflangLinks(i18nHead.value.link, supportedLocales, locales.value)
  ),
})
</script>
Enter fullscreen mode Exit fullscreen mode

The customHreflang: true flag tells app.vue to skip the automatic hreflang injection for this page — the page handles it itself.

In app.vue:

link: [
  // If the page sets customHreflang, trust it to handle its own hreflang.
  // Otherwise, let nuxt-i18n do it automatically (fine for pages
  // where all languages are always available, like the homepage).
  ...(route.meta.customHreflang ? [] : i18nHead.value.link || []),
  { rel: 'canonical', href: canonicalUrl.value },
  // ...favicon etc
]
Enter fullscreen mode Exit fullscreen mode

The Fallback Problem Goes Deeper Than hreflang

Getting hreflang right is the SEO half. The other half is making sure your Vue components don't crash when a locale key is missing.

Here's the kind of thing that breaks silently in dev and loudly in production:

<!-- This explodes if tool['ja'] is undefined -->
<p v-if="tool[locale].hero.scene">
  {{ tool[locale].hero.scene }}
</p>
Enter fullscreen mode Exit fullscreen mode

locale in the template auto-unwraps to a string like 'ja'. If tool['ja'] doesn't exist, you're calling .hero on undefined. Vue will throw, the component won't render, and you'll spend 20 minutes wondering why a page that looks fine in English is broken in Japanese.

The fix is a small helper function in the component's <script setup>:

// Safe accessor — falls back to 'en' if the requested locale isn't in the JSON
function getScene(tool: ProcessedTool): string {
  const content = tool[locale.value] || tool['en']
  return content?.hero?.scene ?? ''
}
Enter fullscreen mode Exit fullscreen mode

Then in the template:

<p v-if="getScene(tool)" class="...">
  {{ getScene(tool) }}
</p>
Enter fullscreen mode Exit fullscreen mode

This pattern — "try the current locale, fall back to English, then use optional chaining" — is worth applying anywhere you access locale-specific nested data in a template. It's more defensive than it looks because you're protecting against two failure modes at once: the locale key not existing and the nested property not existing.


The Same Pattern in useTools()

The composable that processes all tool data has a similar trap. When building category lists, it was spreading locale data directly:

// Dangerous — meta['ja'] is undefined if _meta.json has no 'ja' key
result.push({
  slug: meta.slug,
  ...meta[currentLang]  // 💥 spreading undefined
})
Enter fullscreen mode Exit fullscreen mode

Spreading undefined doesn't always throw immediately — sometimes it just silently produces an object missing all the localized fields. The fix is a tiny helper that normalizes the fallback:

// Inside useTools()
const getLangContent = (obj: any, lang: string) => {
  return obj[lang] || obj['en'] || {}
}

// Now safe everywhere
result.push({
  slug: meta.slug,
  ...getLangContent(meta, currentLang)
})
Enter fullscreen mode Exit fullscreen mode

The || {} at the end is load-bearing. Without it, if somehow neither the requested language nor English exists, you'd spread undefined. With it, you get an empty object and the UI renders with empty strings instead of crashing.


What the Rollout Actually Looks Like

When the Japanese translation for a tool is done — say, the image compressor — the change is:

  1. Add the ja key to that tool's JSON file
  2. Deploy

That's it. No config changes. No feature flags. No separate "ready tools" list to maintain.

The hreflang for that page automatically updates from:

<link rel="alternate" hreflang="en" href="..." />
<link rel="alternate" hreflang="zh-CN" href="..." />
<link rel="alternate" hreflang="x-default" href="..." />
Enter fullscreen mode Exit fullscreen mode

to:

<link rel="alternate" hreflang="en" href="..." />
<link rel="alternate" hreflang="zh-CN" href="..." />
<link rel="alternate" hreflang="ja" href="..." />
<link rel="alternate" hreflang="x-default" href="..." />
Enter fullscreen mode Exit fullscreen mode

The SEO metadata switches to Japanese. The content renders in Japanese. Everything reacts to the single source of truth in the JSON.


Adding a New Language Later

When Korean is ready to roll out, the full change is:

nuxt.config.ts — add one locale entry:

{ code: 'ko', language: 'ko', file: 'ko.json' }
Enter fullscreen mode Exit fullscreen mode

/locales/ko.json — the UI strings (nav, buttons, shared copy)

Each tool JSON — add ko: { ... } when the translation is ready

filterHreflangLinks, getLangContent, getScene, app.vue — untouched. They all read from the locale config at runtime. There's nothing to update.

The whole system is about 60 lines of utility code. The payoff is that adding a language to a page is a data change, not a code change — and that distinction matters a lot when you're doing it 38 times.

If you want to see the end result, BulkPicTools is live with this exact setup. The Japanese rollout is ongoing — you can spot which tools are done by checking whether /ja/tools/... serves localized content or quietly falls back to English while the hreflang stays clean.

Top comments (0)