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": "拖入图片,输出更小的文件。" }
}
}
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',
}
That's the only place a new language gets registered. Everything downstream reads from useI18n().locales at runtime — nothing is hardcoded in utility functions.
javsjp— This trips people up.jais the ISO 639-1 language code for Japanese.jpis the ISO 3166-1 country code for Japan. hreflang wants the language. Alwaysja, neverjp. Same pattern: Chinese iszh, notcn.
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" />
...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))
)
}
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>
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
]
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>
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 ?? ''
}
Then in the template:
<p v-if="getScene(tool)" class="...">
{{ getScene(tool) }}
</p>
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
})
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)
})
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:
- Add the
jakey to that tool's JSON file - 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="..." />
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="..." />
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' }
/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)