Internationalization

ubean uses vue-i18n 11 (Composition API, legacy: false) on the Vue side and @intlify/core in Hono handlers. Language routing is compact prefixes compiled once by compileLocalePaths() and shared by vue-router and Hono.

useI18n comes from vue-i18n directly (auto-import requires autoImports: { vueI18n: true } in ubean.config.ts — off by default; otherwise import it explicitly); destructure t from the returned composer. Framework helpers like setLocale are imported from ubean/client.

Setup

Configure everything in ubean.config.ts. Middleware is mounted by createUbeanApp automatically — you do not need src/middleware/02.i18n.ts.

// ubean.config.ts
import { defineConfig } from 'ubean';

export default defineConfig({
  i18n: {
    defaultLocale: 'en',
    locales: [
      { code: 'en', language: 'en', name: 'English' },
      { code: 'zh', language: 'zh-CN', name: '简体中文' }
    ],
    strategy: 'prefix_except_default',
    baseUrl: 'https://example.com',
    detectBrowserLanguage: {
      cookieName: 'ubean_locale',
      redirectOn: 'root' // default
    },
    vueI18n: {
      fallbackLocale: 'en'
    }
  }
});

locales: ['en', 'zh'] is normalized to { code } objects. i18n: false (or empty locales) disables routing and middleware.

language is BCP 47 for <html lang> and hreflang. Locale codes are reserved URL segments: pages/[id].vue will not match /zh if zh is a locale code.

Locale files

Put messages in src/locales/**/*.{json,json5,yaml,yml,ts,js}. Syntax is Intlify (not ICU): {name}, @:key, a | b | {n} c.

// src/locales/en.json
{
  "hello": "Hello",
  "welcome": "Welcome to ubean",
  "greeting": "Hello, {name}!",
  "items": "no items | one item | {count} items",
  "app": { "name": "ubean" },
  "linked": "Welcome to @:app.name!"
}

Messages load lazily via loadLocale(code). SSR preloads the current locale + fallback; __UBEAN_LOCALE__ serializes only those two catalogs.

Vue usage

<script setup lang="ts">
const { t, d, n, locale } = useI18n();

async function toZh() {
  await setLocale('zh'); // load + cookie + router.replace
}
</script>

<template>
  <h1>{{ t('hello') }}</h1>
  <p>{{ t('greeting', { name: 'World' }) }}</p>
  <button @click="toZh">中文</button>
  <p>Current: {{ locale }}</p>
</template>

Switch language with framework setLocale, not locale.value = 'zh'. setLocale loads messages, writes the cookie, and navigates (no_prefix skips navigation).

Language switcher buttons should iterate config locales (getI18nRuntimeConfig()?.locales), not vue-i18n availableLocales (that list is only loaded catalogs: current + fallback after hydrate).

<script setup lang="ts">
const codes = getI18nRuntimeConfig()?.locales ?? [];
</script>

<template>
  <button v-for="code in codes" :key="code" @click="setLocale(code)">
    {{ code }}
  </button>
</template>

Paths and <Link>

<script setup lang="ts">
const localePath = useLocalePath();
const switchLocalePath = useSwitchLocalePath();
</script>

<template>
  <Link to="/about">About</Link>
  <!-- zh → /zh/about ; default en → /about -->
  <Link to="/about" locale="en">English About</Link>
</template>

useLocalePath('/about', 'zh') → /zh/about. useSwitchLocalePath('zh') rewrites the current route.

Typed keys

ubean prepare / dev / build generate .ubean/i18n.d.ts from the default locale JSON (DefineLocaleMessage). Then t('missing') is a type error. You can also hand-write:

declare module 'vue-i18n' {
  export interface DefineLocaleMessage {
    hello: string;
  }
}

API routes / handlers

In handlers, t() / d() / n() from ubean/i18n read the request AsyncLocalStorage scope. Outside a request they throw — they never fall back to a process-global locale.

import { defineHandler } from 'ubean/server';
import { t, getRequestLocale } from 'ubean/i18n';

export const GET = defineHandler(c => {
  const locale = getRequestLocale(c);
  return c.json({ locale, hello: t('hello') });
});

/api/, /_, /__ still enter ALS (cookie / Accept-Language) but do not rewrite the URL.

Cloudflare Workers need nodejs_compat for AsyncLocalStorage. If ALS is unavailable, t() throws; read c.get('locale') instead.

Routing strategies

Hono and vue-router share the same compiled paths (defaultLocale: 'en', locales en, zh, page /about):

strategyvue-routerHono
prefix_except_default (default)/:locale(zh)?/about/about, /zh/about (no /en/about)
prefix/:locale(en|zh)/about/en/about, /zh/about
prefix_and_default/:locale(en|zh)?/about/about, /en/about, /zh/about
no_prefix/about/about

Detection order (prefixed strategies): URL prefix → cookie → Accept-Language → defaultLocale. Cookie is written. Default redirectOn: 'root': only / 302s from Accept-Language; /about does not. prefix still 302s unprefixed content regardless of redirectOn.

SEO

SSR HTML automatically sets:

  • <html lang> / <html dir>
  • link rel="alternate" hreflang (including language-group catch-all and x-default)
  • canonical (prefix_and_default points the default locale at the unprefixed URL)
  • og:locale / og:locale:alternate

useLocaleHead() updates head after client-side locale switches. Set i18n.baseUrl for absolute hreflang hrefs.