Next.js(next-intl)では、localeを含まないURLにアクセスした場合に、優先言語で表示が切り替わったり、URLが書き換わりましたが、
Nuxt.js(@nuxtjs/i18n)では、defaultLocaleで設定した言語で表示されたので、
両者の挙動を確認して、求める挙動になるようにpluginを作成しました。(テストも作成)
挙動確認
前提
strategyがprefix_expect_default(prefix_and_defaultでも切り替えの挙動は変わらない)
対応言語は、ja(defaultLocale)とen(fallbackLocale)
と設定(下記のコード)しているので、ブラウザの優先言語を変更します。
= 英語(en)が優先される状態に
ja → en-US → en(Chrome/Firefox日本語デフォルト) ※Safari日本語デフォルトは、jaのみ ↓ en → en-US → ja
※ちなみに、3文字以上の場合は先頭2文字で判断されるようにしている事が多いようです。
nuxt.config.ts
import { cookieKey, defaultLocale, locales } from './i18n.config'
export default defineNuxtConfig({
  i18n: {
    strategy: 'prefix_except_default', // https://i18n.nuxtjs.org/guide/routing-strategies
    detectBrowserLanguage: { // NOTE: ブラウザ言語の自動検出ができない -> plugins/setLocale.clientで対応
      useCookie: true,
      cookieKey
    },
    defaultLocale,
    locales,
    vueI18n: './i18n.config.ts'
  },
※「alwaysRedirect: true」を設定してみましたが、挙動が変わらなかったので削除。
i18n.config.ts
cookieKey, defaultLocale, localesは、nuxt.config.tsに書いても問題ないですが、
言語の設定を1ヶ所にまとめたいのと、Vitestから参照しやすいように、ここで定義します。
※localesのnameは、言語切り替えで表示する為に追加しています。
→ layouts/default.vue のv-selectで使用
import { en } from './locales/en'
import { ja } from './locales/ja'
export const cookieKey = 'locale' // <- 'i18n_redirected'
export const defaultLocale = 'ja'
export const locales = [
  { code: 'en', iso: 'en_US', name: 'English' },
  { code: 'ja', iso: 'ja_JP', name: '日本語' }
]
export const fallbackLocale = 'en'
export default {
  legacy: false,
  fallbackLocale,
  silentFallbackWarn: true,
  messages: {
    en,
    ja
  }
}
Cookieなしの場合
| URL | Next.js(next-intl) | Nuxt.js(@nuxtjs/i18n) | 
| / | 英語, URL: /en, Cookie: en | 日本語, URL: 変更なし, Cookie: なし | 
| /en | 英語, URL: 変更なし, Cookie: en | 英語, URL: 変更なし, Cookie: なし | 
| /ja | 日本語, URL: /, Cookie: ja | 日本語, URL: 変更なし, Cookie: なし | 
| /users/sign_up?a | 英語, URL: /en/users/sign_up?a=, Cookie: en | 日本語, URL: 変更なし, Cookie: なし | 
| /en/users/sign_up?a | 英語, URL: 変更なし, Cookie: en | 英語, URL: 変更なし, Cookie: なし | 
| /ja/users/sign_up?a | 日本語, URL: /users/sign_up?a=, Cookie: ja | 日本語, URL: 変更なし, Cookie: なし | 
考察
Next.js(next-intl)は、URLが一意(defaultLocaleのパスは消える)で、必ずCookieがセットされる。
Nuxt.js(@nuxtjs/i18n)は、URLやCookieは変わらない。※CSR/SSR共に挙動は変わらない。
※switchLocalePath(Nuxt)やrouter.replaceでlocale指定(Next)を呼び出すと、URLとCookieが変わる。
Next.js(next-intl)は、優先言語で先に一致したもの、
Nuxt.js(@nuxtjs/i18n)は、優先言語からjaを消しても、日本語(defaultLocale)が表示されました。
Cookieありの場合
| URL | Cookie | Next.js(next-intl) | Nuxt.js(@nuxtjs/i18n) | 
| / | ja | 日本語, URL: 変更なし, Cookie: 変更なし | ※Next.jsと同じ | 
| en | 英語, URL: /en, Cookie: 変更なし | ※Next.jsと同じ | |
| /en | ja | 英語, URL: 変更なし, Cookie: en | 英語, URL: 変更なし, Cookie: 変更なし(ja) | 
| en | 英語, URL: 変更なし, Cookie: 変更なし | ※Next.jsと同じ | |
| /ja | ja | 日本語, URL: /, Cookie: 変更なし | 日本語, URL: 変更なし, Cookie: 変更なし | 
| en | 日本語, URL: /, Cookie: ja | 日本語, URL: 変更なし, Cookie: 変更なし(en) | 
考察
共に、URLにlocaleがある場合は、URLのが優先される。
URLの書き換えとCookieの挙動は、上記(Cookieなし)の場合と変わらない。
Next.js(next-intl)は、URLとCookieの一貫性が保たれる。
Nuxt.js(@nuxtjs/i18n)では、URLにlocaleがない場合はCookie使うので、前回localeありでも、次回はそれ以前に切り替えた言語が表示される。
仕様の違いですが、localePathの付け忘れでも言語が切り替わってしまう。Next.js(next-intl)は、URLが書き換わる。
方針
・URLは一意にしたい → defaultLocaleが含まないように書き換える
・Cookieなしの場合は、ブラウザの優先言語で表示言語・URLを切り替えたい
URLを一意にした場合、Cookieと異なると誤った言語が表示されてしまう。
ここだけ対応するのもありですが、仕様に一貫性がなくなってしまうので、Cookieも更新する事にします。
= Next.js(next-intl)の挙動に揃える。
middleware: 遷移時に毎回実行される。
 → パラメータのfromとtoが一致するかで判断できますが。。。
 → layoutsが1つなら、layoutsから呼び出すでも行けそうですが。。。
plugin: 初回アクセス時のみ実行される。
 → 素直にpluginを使います。
plugin実装
Cookieをセットしただけでは、localeは変わらない。
既にCookieを参照してlocaleは決まっている。→ $i18n.locale.valueで取得できる。
$i18n.locale.valueを書き換えても、表示言語は変わらない。
switchLocalePathを呼び出しても変わらない。
location.replaceとlocation.reloadを使う事にしました。
画面表示の不自然な描写(一度、表示されてから書き換わる等)もありませんでした。
Cookieが使えない場合、リダイレクトループするので、一部、localStorageも併用します。
最新のコードはこちら → plugins/setLocale.client.ts
import { cookieKey, locales, defaultLocale, fallbackLocale } from '~/i18n.config'
// ブラウザ言語の自動検出、デフォルト言語の場合はURLから言語を削除
export default defineNuxtPlugin(({ $config }): any => {
  const browserLocales = window.navigator.languages
  const $route = useRoute()
  const fullPath = $route.fullPath.match(/^\/([^/]*)(.*)$/)
  const cookieLocale = useCookie(cookieKey)
  /* c8 ignore next */ // eslint-disable-next-line no-console
  if ($config.public.debug) { console.log('plugins/setLocale.client', browserLocales, fullPath, cookieLocale.value) }
  if (browserLocales == null) {
    /* c8 ignore next */ // eslint-disable-next-line no-console
    if ($config.public.debug) { console.log('...Skip') }
    return
  }
  // URLに言語が含まれる -> Cookie更新、デフォルト言語の場合はURLから言語を削除
  if (fullPath != null) {
    const locale = fullPath[1]
    if (locales.filter((item: any) => item.code === locale).length >= 1) {
      /* c8 ignore next */ // eslint-disable-next-line no-console
      if ($config.public.debug) { console.log('URL: cookieLocale', locale) }
      cookieLocale.value = locale
      if (locale === defaultLocale) {
        const path = fullPath[2] === '' ? '/' : fullPath[2]
        /* c8 ignore next */ // eslint-disable-next-line no-console
        if ($config.public.debug) { console.log('URL: location.replace', path) }
        location.replace(path)
      }
      return locale
    }
  }
  // Cookieあり -> デフォルト言語以外の場合はURLに言語を追加
  if (cookieLocale.value != null) {
    const locale = cookieLocale.value
    if (locales.filter((item: any) => item.code === locale).length >= 1) {
      if (locale !== defaultLocale) {
        const path = $route.fullPath === '/' ? `/${locale}` : `/${locale}${$route.fullPath}`
        /* c8 ignore next */ // eslint-disable-next-line no-console
        if ($config.public.debug) { console.log('Cookie: location.replace', path) }
        location.replace(path)
      }
      return
    }
  }
  // ブラウザの優先言語と一致 -> Cookie更新(ブラウザの優先言語)してリロード
  for (const browserLocale of browserLocales) {
    const locale = browserLocale.slice(0, 2) // NOTE: 先頭2文字で判定
    if (locales.filter((item: any) => item.code === locale).length >= 1) {
      /* c8 ignore next */ // eslint-disable-next-line no-console
      if ($config.public.debug) { console.log('browserLocales: cookieLocale', locale) }
      cookieLocale.value = locale
      const storageKey = 'browser-locale'
      if (localStorage.getItem(storageKey) !== locale) { // NOTE: Cookieが使えない場合にリダイレクトループを防ぐ
        /* c8 ignore next */ // eslint-disable-next-line no-console
        if ($config.public.debug) { console.log('browserLocales: location.reload') }
        localStorage.setItem(storageKey, locale)
        location.reload()
      }
      return locale
    }
  }
  // Cookie更新(fallbackLocale)
  /* c8 ignore next */ // eslint-disable-next-line no-console
  if ($config.public.debug) { console.log('fallbackLocale: cookieLocale', fallbackLocale) }
  cookieLocale.value = fallbackLocale
  return fallbackLocale
})
※ログを入れていますが、location.replaceやlocation.reloadすると前のは無くなってしまう。
Vitestで品質担保
長いので、テストコードの記載は省略します。
最新のコードはこちら → test/plugins/setLocale.client.test.ts
実行結果
 ✓ test/plugins/setLocale.client.test.ts (25)
   ✓ setLocale.client.ts (25)
     ✓ [優先言語がない]undefined
     ✓ 優先言語に対応言語が含まれない (8)
       ✓ URLに言語が含まれない (5)
         ✓ [Cookieの言語がない]fallbackLocale
         ✓ [Cookieの言語が対応言語に存在しない]fallbackLocale
         ✓ testExistsCookie (3)
           ✓ [Cookieの言語がデフォルト言語]undefined
           ✓ [URLがルート。Cookieの言語がデフォルト言語以外]undefined、言語付きURLに書き換えられる
           ✓ [URLにパラメータがある。Cookieの言語がデフォルト言語以外]undefined、言語付きURLに書き換えられる
       ✓ testIncludeURL (3)
         ✓ URLの言語がデフォルト言語 (2)
           ✓ [URLがルート]defaultLocale、言語なしURLに書き換えられる
           ✓ [URLにパラメータがある]defaultLocale、言語なしURLに書き換えられる
         ✓ [URLの言語がデフォルト言語以外]URLの言語
     ✓ 優先言語にデフォルト言語が含まれる (8)
       ✓ URLに言語が含まれない (5)
         ✓ [Cookieの言語がない]優先言語が対応言語と最初に一致した言語、リロードされる
         ✓ [Cookieの言語が対応言語に存在しない]優先言語が対応言語と最初に一致した言語、リロードされる
         ✓ testExistsCookie (3)
           ✓ [Cookieの言語がデフォルト言語]undefined
           ✓ [URLがルート。Cookieの言語がデフォルト言語以外]undefined、言語付きURLに書き換えられる
           ✓ [URLにパラメータがある。Cookieの言語がデフォルト言語以外]undefined、言語付きURLに書き換えられる
       ✓ testIncludeURL (3)
         ✓ URLの言語がデフォルト言語 (2)
           ✓ [URLがルート]defaultLocale、言語なしURLに書き換えられる
           ✓ [URLにパラメータがある]defaultLocale、言語なしURLに書き換えられる
         ✓ [URLの言語がデフォルト言語以外]URLの言語
     ✓ 優先言語にデフォルト言語以外の対応言語が含まれる (8)
       ✓ URLに言語が含まれない (5)
         ✓ [Cookieの言語がない]優先言語が対応言語と最初に一致した言語、リロードされる
         ✓ [Cookieの言語が対応言語に存在しない]優先言語が対応言語と最初に一致した言語、リロードされる
         ✓ testExistsCookie (3)
           ✓ [Cookieの言語がデフォルト言語]undefined
           ✓ [URLがルート。Cookieの言語がデフォルト言語以外]undefined、言語付きURLに書き換えられる
           ✓ [URLにパラメータがある。Cookieの言語がデフォルト言語以外]undefined、言語付きURLに書き換えられる
       ✓ testIncludeURL (3)
         ✓ URLの言語がデフォルト言語 (2)
           ✓ [URLがルート]defaultLocale、言語なしURLに書き換えられる
           ✓ [URLにパラメータがある]defaultLocale、言語なしURLに書き換えられる
         ✓ [URLの言語がデフォルト言語以外]URLの言語
 Test Files  1 passed (1)
      Tests  25 passed (25)
   Start at  21:26:51
   Duration  632ms
言語切り替えselect実装

layouts/default.vue
      <v-select
        v-if="locales.length >= 2"
        v-model="switchLocale"
        :items="locales"
        item-title="name"
        item-value="code"
        density="compact"
        variant="underlined"
        hide-details
        class="ml-1 mr-4 mb-2 switch-locale"
        style="max-width: 94px"
        @update:model-value="navigateTo(switchLocalePath(switchLocale))"
      />
const { locale, locales } = useI18n()
const switchLocalePath = useSwitchLocalePath()
const switchLocale = ref(locale.value)
